Docs

Tweek API Reference

Read and write tasks and calendars over a simple REST API — the same data you see in Tweek. Automate your own account with a personal key, or build an integration other people connect to with OAuth 2.0.

Tweek connecting to your app over the API

Overview

The base URL is https://tweek.so/api/v1. Every request goes over HTTPS and carries credentials.

Two ways to authenticate, depending on whose data you're working with:

Base URL
https://tweek.so/api/v1

Quickstart

From nothing to a created task in two calls. You'll need credentials first — the fastest is a personal API key for your own account, or Connect with Tweek for a multi-user app. The examples below use a personal key; with an OAuth token, send Authorization: Bearer ACCESS_TOKEN instead.

1. List the user's calendars. This also confirms your key works. Every task lives in a calendar, so you need a calendar id first.

curl https://tweek.so/api/v1/calendars \
  -H 'X-API-Key: YOUR_KEY'

2. Create a task in one of those calendars, on a date.

curl -X POST https://tweek.so/api/v1/tasks \
  -H 'X-API-Key: YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{ "calendarId": "CALENDAR_ID", "text": "Buy milk", "done": false, "date": "2026-07-10" }'

You get back { "id": "..." }. That's a working integration — the rest of this page is the detail.

Personal API key

The quickest way in, for working with your own account — a script, a Zapier or n8n automation, or another app you use yourself. A personal key acts as you, with full access to your data, so there are no scopes to manage.

In Tweek, open Profile → API Settings, and under Personal API keys create a key. Copy it right away — Tweek keeps only a hash, so it can't show the key again.

Send it as an X-API-Key header. Every example on this page works with a personal key — just swap the Authorization: Bearer header for this one:

curl https://tweek.so/api/v1/calendars \
  -H 'X-API-Key: YOUR_KEY'
NoteTreat a personal key like a password — anyone who has it gets full access to your Tweek. Keep it server-side, never in client code or a public repo. To revoke it, delete it in API Settings; it stops working immediately.

Connect with Tweek

Tweek uses OAuth 2.0, the Authorization Code flow with PKCE. A few things to know first:

Register an app

Self-serve app registration isn't available yet. To get a client_id, email us at hello@tweek.so with your app's name (users see it on the consent screen), your redirect URI(s), and the scopes it needs. We'll set it up and send you a client_id — a public identifier, with no secret to manage. The redirect_uri you send in the flow must match a registered one exactly — same scheme, host, path and trailing slash, no wildcards.

Make a PKCE pair

For each sign-in, make a one-time code_verifier and its code_challenge. Keep the verifier — you'll need it when you swap the code. Only S256 is accepted.

code_verifier=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')
code_challenge=$(printf '%s' "$code_verifier" \
  | openssl dgst -binary -sha256 | openssl base64 | tr '+/' '-_' | tr -d '=')

Send the user to the consent screen

Open this in the user's browser. They sign in if needed, then approve the scopes.

https://tweek.so/oauth
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https%3A%2F%2Fyour.app%2Fcallback
  &scope=tasks:read tasks:write offline_access
  &state=RANDOM_STRING
  &code_challenge=CODE_CHALLENGE
  &code_challenge_method=S256
NoteGenerate state yourself and check it on the callback. Tweek returns it unchanged, and it's what protects you from forged callbacks.

Get the code back

On Allow, the browser lands on your redirect_uri with a code that's good for 5 minutes. On Deny, you get ?error=access_denied instead.

https://your.app/callback?code=AUTH_CODE&state=RANDOM_STRING

Swap the code for tokens

Do it right away — the code is single-use. Send it as form data.

curl -X POST https://tweek.so/api/v1/oauth2/token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d grant_type=authorization_code \
  -d code=AUTH_CODE \
  -d redirect_uri=https://your.app/callback \
  -d client_id=YOUR_CLIENT_ID \
  -d code_verifier=CODE_VERIFIER
{
  "access_token": "eyJhbGciOiJSUzI1Ni...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "...",        // only if you asked for offline_access
  "scope": "tasks:read tasks:write offline_access"
}

Now you have a token — jump to the Quickstart to make your first call.

Refreshing tokens

Access tokens last an hour. Trade the refresh token for a new pair before it runs out:

curl -X POST https://tweek.so/api/v1/oauth2/token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d grant_type=refresh_token \
  -d refresh_token=REFRESH_TOKEN \
  -d client_id=YOUR_CLIENT_ID
NoteRefresh tokens rotate — each one works once. Save the new one from every response and drop the old.

To disconnect, revoke the refresh token at POST /api/v1/oauth2/revoke. Access tokens are stateless, so they can't be revoked — they just expire.

Validating tokens

Access tokens are RS256 JWTs. If your app is the resource server, verify them offline with our public keys — no call to Tweek per request.

CheckValue
SignaturePublic keys at GET /api/v1/oauth2/jwks
Issuer (iss)https://tweek.so
Audience (aud)https://tweek.so/api

Useful claims: sub (user id), scope, client_id, exp.

Need a live answer instead — whether a token is revoked, or checking an opaque refresh token — call POST /api/v1/oauth2/introspect with a token parameter (RFC 7662). It returns { "active": true, ... } or { "active": false }.

Scopes

Scopes apply to OAuth apps only — a personal key isn't scoped. Ask for the least you need. Scopes go space-separated in the authorize URL, and the user sees them on the consent screen.

ScopeLets your app
tasks:readSee the user's tasks
tasks:writeCreate, update, and delete tasks
calendars:readSee the user's calendars
colors:readSee the user's custom colors
offline_accessGet a refresh token

Endpoints

Everything is under /api/v1. Authenticate every call with either X-API-Key: YOUR_KEY (personal) or Authorization: Bearer ACCESS_TOKEN (OAuth). JSON bodies as application/json.

MethodPathScopeUse it to
GET/api/v1/taskstasks:readList tasks in a calendar
GET/api/v1/tasks/{id}tasks:readGet one task
POST/api/v1/taskstasks:writeCreate a task
PATCH/api/v1/tasks/{id}tasks:writeUpdate a task
DELETE/api/v1/tasks/{id}tasks:writeDelete a task
GET/api/v1/calendarscalendars:readList calendars
GET/api/v1/custom-colorscolors:readList custom colors

Tasks

Every task lives in a calendar. Get calendar ids from GET /api/v1/calendars, then pass one as calendarId — it's required to list or create tasks.

List tasks

Pass calendarId as a query param. Optionally narrow by date with dateFrom and dateTo (both YYYY-MM-DD, inclusive). The filter matches a task's date as well as its dtStart (so recurring bases anchored in the window are included). Tasks in someday lists have no calendar date, so a date filter leaves them out.

curl 'https://tweek.so/api/v1/tasks?calendarId=CALENDAR_ID' \
  -H 'X-API-Key: YOUR_KEY'

The response is paginated. Tasks are in data; nextDocId is the cursor for the next page (null on the last one), and pageSize is set by the server.

{
  "data": [ /* task objects */ ],
  "pageSize": 100,
  "nextDocId": "TASK_ID"
}

For the next page, pass that nextDocId back as startAt, and repeat until nextDocId is null.

curl 'https://tweek.so/api/v1/tasks?calendarId=CALENDAR_ID&startAt=NEXT_DOC_ID' \
  -H 'X-API-Key: YOUR_KEY'

Expanding recurring tasks

A plain date-filtered list returns only stored documents — a recurring series shows up as its base task, not as one row per day, so a single-day query misses older series that merely recur onto that day. Add expand=occurrences and the server expands recurring tasks into their per-day occurrences inside the window — the result matches what the user sees in the app for those dates.

curl 'https://tweek.so/api/v1/tasks?calendarId=CALENDAR_ID&expand=occurrences&dateFrom=2026-07-06&dateTo=2026-07-12' \
  -H 'X-API-Key: YOUR_KEY'
{
  "data": [
    { "id": "TASK_ID_20260706", "date": "2026-07-06", "recurringTodoId": "TASK_ID", "virtual": true /* … */ }
  ],
  "pageSize": 7,
  "nextDocId": null
}

Rules of the expanded mode:

RuleDetails
Bounded windowdateFrom and dateTo are required, at most 92 days apart.
No paginationThe whole window comes back in one response; nextDocId is always null and startAt is not allowed.
Computed occurrencesOccurrences that exist only by the rule carry "virtual": true and the id <taskId>_yyyyMMdd. Occurrences the user already touched (checked off, edited) are stored documents and come back as such.
QuotaEach returned task — computed or stored — counts as one read.
timezoneOptional IANA timezone (e.g. Europe/Berlin) used to resolve timezone-dependent recurrence exceptions; defaults to UTC.
NoteA virtual id is a real address: GET, PATCH and DELETE /api/v1/tasks/<taskId>_yyyyMMdd read or act on that single occurrence — see Recurring tasks.

The task object

On create, calendarId, text, and done are required. A task also needs a placement — either date or listId (see the note).

FieldTypeNotes
idstringReturned by the API; you don't send it
calendarIdstringRequired. Which calendar the task is in
textstringRequired. The task title
donebooleanRequired. Whether it's checked off
datestring · nullYYYY-MM-DD for a dated task. On read it can also hold a someday-list id — see Someday lists
listIdstring · nullSomeday-list id, used instead of date to place a task in a someday list
colorstring · nullA color id — see Default colors
notestring · nullFree text
notifyAtstring · nullReminder time, ISO 8601. Paid plan.
checklistarray · nullSubtasks — see the subtask object. Paid plan.
freqnumber · nullHow the task repeats — see Recurring tasks. Paid plan.
recurrencestring · nullAn RRULE — for a custom repeat (freq: 7) or a task synced from an external calendar. Paid plan.
dtStartstring · nullAnchor date a repeat counts from, YYYY-MM-DD. Paid plan.
gcalbooleanWhether the task is synced with the user's connected Google Calendar. On create, true also adds it there; default false.
isoDatestring · nullRead-only. Date-time of the task in ISO 8601, on tasks synced from an external calendar integration.
sourceobject · nullApple Calendar / Reminders sync info — see the source object. Set by the apps, not by you.
deletedboolean · nullRead-only. true on a stored occurrence of a recurring series that was deleted — the document stays as a marker so the series skips that day. Such tasks never appear in the app or in expanded listings.
virtualbooleanRead-only. true on a computed occurrence of a recurring task in an expanded listing; never stored and never sent on write.
NoteA task needs a placement or it won't show up anywhere. Set either date (a dated task) or listId (a task in a someday list).
NoteFree plans take a subset of fields. Reminders (notifyAt) and checklists (checklist) need a paid plan, and color is limited to blank, pink and yellowish. Sending a paid-only field on a free account returns 400.

The subtask object

Items of a task's checklist:

FieldTypeNotes
idstringRequired. Your id for the item — any non-empty string, unique within the checklist
textstringRequired. The item text
donebooleanRequired. Whether the item is checked off
highlightedboolean · nullWhether the item is starred
indentnumber · nullIndentation level, 02. Default 0
variantstring · nullchecklistItem (default) or header — a header row inside the checklist

The source object

A task synced from Apple Calendar or Apple Reminders carries a source object. The devices own these tasks — treat the field as read-only bookkeeping.

FieldTypeNotes
identifierstringId of the task in the integration source
typestringapple (Apple Calendar) or reminders (Reminders.app)
calendarIdentifierstring · nullId of the calendar or list in the source
lastModifiedDatestring · nullLast modification date, ISO 8601

Someday lists

Besides the dated grid, each calendar has someday lists — undated sections for tasks without a day. The lists themselves come from GET /api/v1/calendars: each calendar carries a lists array, and each list's id is what you use as the task's listId.

To put a task in a someday list, set listId to the list's id instead of giving it a date:

curl -X POST https://tweek.so/api/v1/tasks \
  -H 'X-API-Key: YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{ "calendarId": "CALENDAR_ID", "text": "Read that book", "done": false, "listId": "LIST_ID" }'
NoteOn read, a task sitting in a someday list may carry the list id in its date field — that's the historical storage format, and listId holds the same id. So don't assume date parses as YYYY-MM-DD: if it matches one of the calendar's list ids, the task is a someday task, not a dated one. Someday tasks are also excluded from date-filtered listings.

Create a task

curl -X POST https://tweek.so/api/v1/tasks \
  -H 'Authorization: Bearer ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "calendarId": "CALENDAR_ID", "text": "Buy milk", "done": false, "date": "2026-07-10", "gcal": false }'
// 201 Created
{ "id": "GENERATED_TASK_ID" }

Update a task

Send only the fields you want to change — for example, to check it off. You can't move a task between calendars this way; calendarId is fixed once the task exists.

curl -X PATCH https://tweek.so/api/v1/tasks/TASK_ID \
  -H 'Authorization: Bearer ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "done": true }'

For a task in a recurring series the id may be a virtual-occurrence id from an expanded listing (updates just that occurrence), and the optional updateType query parameter picks the series mode — the same three choices the apps offer:

updateTypeApplies the change to
only_thisThis occurrence only. Editing the series master or its acting base first preserves the current content for the future occurrences.
this_and_futureThis occurrence and all later ones.
all_linkedEvery occurrence of the series.
curl -X PATCH 'https://tweek.so/api/v1/tasks/TASK_ID_20260706?updateType=this_and_future' \
  -H 'X-API-Key: YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{ "text": "Team standup (new room)" }'
NoteWith updateType set, the body may only change text, note, color, checklist, source, done and notifyAt. Without it, the call keeps its plain single-document behavior. Tasks synced from Apple Calendar / Reminders don't take updateType — the device owns those series.

Delete a task

curl -X DELETE https://tweek.so/api/v1/tasks/TASK_ID \
  -H 'Authorization: Bearer ACCESS_TOKEN'
# → 204 No Content

For a recurring series, the same updateType parameter applies: only_this removes a single occurrence (the series keeps skipping that day — also what deleting by a virtual id does), this_and_future ends the series from that occurrence on, and all_linked permanently deletes every occurrence.

Recurring tasks

There's no separate endpoint for repeats — you make a task recur through the same create and update calls, by setting freq on it. For the built-in patterns Tweek builds the schedule for you; for anything else use freq: 7 and supply your own RRULE in recurrence. Recurrence needs a paid plan.

freqRepeats
0Never — a one-off task (the default)
1Daily
2Weekly
3Monthly
4Annually
5Every weekday (Mon–Fri)
6Every two weeks
7Custom — set your own RRULE in recurrence

Set dtStart to the date the series starts from (YYYY-MM-DD), normally the same as the first occurrence's date. A weekly task from July 13:

curl -X POST https://tweek.so/api/v1/tasks \
  -H 'X-API-Key: YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{ "calendarId": "CALENDAR_ID", "text": "Team standup", "done": false, "date": "2026-07-13", "dtStart": "2026-07-13", "freq": 2 }'

For a custom cadence, set freq to 7 and pass an RFC 5545 RRULE in recurrence — e.g. every other Monday and Wednesday:

{ /* … */ "freq": 7, "recurrence": "RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE" }

In storage a repeating task is one base task carrying freq, recurrence and dtStart — not one row per date. A plain list returns exactly those documents; to get the actual per-day occurrences, list with expand=occurrences. Each computed occurrence carries "virtual": true and the id <taskId>_yyyyMMdd, which you can GET, PATCH or DELETE directly to act on that single occurrence — and the updateType parameter on update/delete extends a change to the rest of the series.

NoteOn read you may also see isBase, recurringTodoId and isBaseDeleted — series bookkeeping Tweek maintains, which you don't set yourself.

Default colors

A task's color field takes a color id. These are the built-in colors:

ColorIdFill
blankNo highlight
pink#CD2C54
yellowish#FDEF5D
black#000000
grey#C2C2C2
cornflower#5167F4
mango#FFAF23
greenish#21FFA1
lilac#D95CFE
NoteFree accounts can use blank, pink and yellowish. The other built-in colors, and any custom colors, need a paid plan — sending one on a free account returns 400.

Calendars & colors

GET/api/v1/calendarscalendars:read

Lists the calendars the user can see — their own and the ones shared with them. You need a calendar's id to list or create tasks.

FieldTypeNotes
idstringCalendar id — the calendarId you pass to the task endpoints
namestringCalendar name
ownerIdstringUser id of the calendar's owner
rolestringThe user's role in this calendar — see below
listsarrayThe calendar's someday lists, each { id, name, hidden, webHidden } (hidden/webHidden — whether the section is collapsed on mobile / on the web)
isDefaultbooleanWhether it's the user's default calendar

Roles:

roleThe user can
ROLE_OWNEREverything — they own the calendar
ROLE_EDITOREdit tasks, but not own the calendar
ROLE_VIEWEROnly view the calendar's data
GET/api/v1/custom-colorscolors:read

Lists the user's custom colors. Each is { id, color, backgroundColor } — the id goes into a task's color field, color is the text color and backgroundColor the fill, both HEX. Custom colors on tasks need a paid plan.

Quotas

API usage is metered per account, separately for reads and writes:

Your current usage and limits are in Tweek under Profile → API Settings; paid plans get higher limits, and quotas reset monthly. When a quota runs out the API answers 429 until the reset.

Errors

OAuth errors

The authorize and token endpoints return errors as { "error": "...", "error_description": "..." }.

ErrorWhat it means & what to do
invalid_requestA parameter is missing or wrong — often PKCE isn't S256. Check the authorize URL.
invalid_grantThe code or refresh token is wrong, expired, or already used. Codes last 5 minutes; refresh tokens rotate. Start over with the latest one.
invalid_clientThe client_id isn't registered. Double-check it.
access_deniedThe user clicked Deny on the consent screen.

REST API errors

The /api/v1 endpoints signal failures with an HTTP status code.

StatusWhat it means & what to do
400The request body failed validation — check required fields, field formats, and free-plan limits.
401No credentials, or the token or key is expired or invalid. Refresh the token, re-run the flow, or check your API key.
403Authenticated, but the token lacks the scope this endpoint needs. (Personal keys aren't scoped.)
404No resource with that id — e.g. an unknown task or calendar.
429Too many requests — either the request rate, or a monthly read/write quota is exhausted. Back off and retry; check usage in API Settings.
500Something went wrong on our side. Retry; if it persists, contact support.

Most REST errors carry the standard JSON body:

{ "statusCode": 403, "message": "...", "error": "Forbidden" }

A 400 from body validation is the exception: it returns a field-keyed tree — one _errors array per invalid field — that you can map straight onto your input.

// 400 — failed validation
{
  "_errors": [],
  "text": { "_errors": ["Required"] },
  "date": { "_errors": ["Invalid date"] }
}
Back to Docs