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.

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:
- Personal API key — for your own account: a script, an automation, or another app you use yourself. Generate a key in Tweek and send it as
X-API-Key. - Connect with Tweek (OAuth 2.0) — for an app other Tweek users sign into. Each user approves scopes on a consent screen, and you get access tokens.
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'
Connect with Tweek
Tweek uses OAuth 2.0, the Authorization Code flow with PKCE. A few things to know first:
- Every app is a public client — there's no client secret. SPAs, mobile, and CLIs can't keep one safe, so PKCE protects the flow instead.
- Access tokens are JWTs, good for 1 hour.
- Ask for the
offline_accessscope if you want a refresh token.
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
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
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.
| Check | Value |
|---|---|
| Signature | Public 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.
| Scope | Lets your app |
|---|---|
| tasks:read | See the user's tasks |
| tasks:write | Create, update, and delete tasks |
| calendars:read | See the user's calendars |
| colors:read | See the user's custom colors |
| offline_access | Get 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.
| Method | Path | Scope | Use it to |
|---|---|---|---|
| GET | /api/v1/tasks | tasks:read | List tasks in a calendar |
| GET | /api/v1/tasks/{id} | tasks:read | Get one task |
| POST | /api/v1/tasks | tasks:write | Create a task |
| PATCH | /api/v1/tasks/{id} | tasks:write | Update a task |
| DELETE | /api/v1/tasks/{id} | tasks:write | Delete a task |
| GET | /api/v1/calendars | calendars:read | List calendars |
| GET | /api/v1/custom-colors | colors:read | List 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:
| Rule | Details |
|---|---|
| Bounded window | dateFrom and dateTo are required, at most 92 days apart. |
| No pagination | The whole window comes back in one response; nextDocId is always null and startAt is not allowed. |
| Computed occurrences | Occurrences 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. |
| Quota | Each returned task — computed or stored — counts as one read. |
timezone | Optional IANA timezone (e.g. Europe/Berlin) used to resolve timezone-dependent recurrence exceptions; defaults to UTC. |
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).
| Field | Type | Notes |
|---|---|---|
id | string | Returned by the API; you don't send it |
calendarId | string | Required. Which calendar the task is in |
text | string | Required. The task title |
done | boolean | Required. Whether it's checked off |
date | string · null | YYYY-MM-DD for a dated task. On read it can also hold a someday-list id — see Someday lists |
listId | string · null | Someday-list id, used instead of date to place a task in a someday list |
color | string · null | A color id — see Default colors |
note | string · null | Free text |
notifyAt | string · null | Reminder time, ISO 8601. Paid plan. |
checklist | array · null | Subtasks — see the subtask object. Paid plan. |
freq | number · null | How the task repeats — see Recurring tasks. Paid plan. |
recurrence | string · null | An RRULE — for a custom repeat (freq: 7) or a task synced from an external calendar. Paid plan. |
dtStart | string · null | Anchor date a repeat counts from, YYYY-MM-DD. Paid plan. |
gcal | boolean | Whether the task is synced with the user's connected Google Calendar. On create, true also adds it there; default false. |
isoDate | string · null | Read-only. Date-time of the task in ISO 8601, on tasks synced from an external calendar integration. |
source | object · null | Apple Calendar / Reminders sync info — see the source object. Set by the apps, not by you. |
deleted | boolean · null | Read-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. |
virtual | boolean | Read-only. true on a computed occurrence of a recurring task in an expanded listing; never stored and never sent on write. |
date (a dated task) or listId (a task in a someday list).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:
| Field | Type | Notes |
|---|---|---|
id | string | Required. Your id for the item — any non-empty string, unique within the checklist |
text | string | Required. The item text |
done | boolean | Required. Whether the item is checked off |
highlighted | boolean · null | Whether the item is starred |
indent | number · null | Indentation level, 0–2. Default 0 |
variant | string · null | checklistItem (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.
| Field | Type | Notes |
|---|---|---|
identifier | string | Id of the task in the integration source |
type | string | apple (Apple Calendar) or reminders (Reminders.app) |
calendarIdentifier | string · null | Id of the calendar or list in the source |
lastModifiedDate | string · null | Last 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" }'
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:
updateType | Applies the change to |
|---|---|
only_this | This occurrence only. Editing the series master or its acting base first preserves the current content for the future occurrences. |
this_and_future | This occurrence and all later ones. |
all_linked | Every 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)" }'
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.
freq | Repeats |
|---|---|
0 | Never — a one-off task (the default) |
1 | Daily |
2 | Weekly |
3 | Monthly |
4 | Annually |
5 | Every weekday (Mon–Fri) |
6 | Every two weeks |
7 | Custom — 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.
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:
| Color | Id | Fill |
|---|---|---|
blank | No highlight | |
pink | #CD2C54 | |
yellowish | #FDEF5D | |
black | #000000 | |
grey | #C2C2C2 | |
cornflower | #5167F4 | |
mango | #FFAF23 | |
greenish | #21FFA1 | |
lilac | #D95CFE |
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
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.
| Field | Type | Notes |
|---|---|---|
id | string | Calendar id — the calendarId you pass to the task endpoints |
name | string | Calendar name |
ownerId | string | User id of the calendar's owner |
role | string | The user's role in this calendar — see below |
lists | array | The calendar's someday lists, each { id, name, hidden, webHidden } (hidden/webHidden — whether the section is collapsed on mobile / on the web) |
isDefault | boolean | Whether it's the user's default calendar |
Roles:
role | The user can |
|---|---|
ROLE_OWNER | Everything — they own the calendar |
ROLE_EDITOR | Edit tasks, but not own the calendar |
ROLE_VIEWER | Only view the calendar's data |
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:
- Reads — every task, calendar, or color returned counts as one read. A page of 100 tasks is 100 reads, and so is an expanded window with 100 occurrences.
- Writes — every create, update, or delete call counts as one write.
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": "..." }.
| Error | What it means & what to do |
|---|---|
invalid_request | A parameter is missing or wrong — often PKCE isn't S256. Check the authorize URL. |
invalid_grant | The code or refresh token is wrong, expired, or already used. Codes last 5 minutes; refresh tokens rotate. Start over with the latest one. |
invalid_client | The client_id isn't registered. Double-check it. |
access_denied | The user clicked Deny on the consent screen. |
REST API errors
The /api/v1 endpoints signal failures with an HTTP status code.
| Status | What it means & what to do |
|---|---|
400 | The request body failed validation — check required fields, field formats, and free-plan limits. |
401 | No credentials, or the token or key is expired or invalid. Refresh the token, re-run the flow, or check your API key. |
403 | Authenticated, but the token lacks the scope this endpoint needs. (Personal keys aren't scoped.) |
404 | No resource with that id — e.g. an unknown task or calendar. |
429 | Too many requests — either the request rate, or a monthly read/write quota is exhausted. Back off and retry; check usage in API Settings. |
500 | Something 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