subscribing, and what an event carries
Webhooks
Subscribe to changes in a user's tasks. An event carries an id and a type, never the object.
{
"id": "evt_aCyx2ukBPQnIfeQ7",
"type": "todo.updated",
"occurredAt": "2026-09-15T18:02:11.000Z",
"data": { "object": "todo", "id": "9C3F1A72-8E4F-4B21-A0D5-3F7B2C1E6A94", "sub": "kQ8vM2xRpL4Ns1Zd7Ty6Ye3Wu0Bg5Mj2Hc7Qf4Ln8A" }
}Events are thin for two reasons. The server cannot read the row, so a fat event could only carry an envelope no subscriber can open. And a webhook endpoint is a URL, so sending content there makes your log retention part of the content's blast radius.
Subscribe
POST /v1/webhooks registers a URL and the events you want.
curl -s https://api.inittasks.com/v1/webhooks \
-X POST \
-H "Authorization: Bearer $INITTASKS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://standup.example/hooks/inittasks",
"events": ["todo.created", "todo.updated", "todo.deleted"],
"description": "standup bot"
}'Use your own endpoint's URL: standup.example does not exist, so this exact call answers 422 with host did not resolve. The server resolves the name when you subscribe.
{
"id": "wh_9Kd2mQx4Np7Rt3Vb",
"url": "https://standup.example/hooks/inittasks",
"events": ["todo.created", "todo.updated", "todo.deleted"],
"description": "standup bot",
"active": true,
"createdAt": "2026-09-15T18:00:00.000Z",
"secret": "whsec_R2x9Td4Vp7Km1Ns6Yb3Qe0Wu8Lj5Hg2Cf4Xa7Zr1DoQ"
}The URL must be:
httpswith no user name or password in it, at most 2048 characters;- on port 443 (the default). Any other port is refused;
- a name that resolves only to public addresses. It is checked when you subscribe and again before every delivery;
- not an
inittasks.comhost.
An account can have at most 25 subscriptions. Duplicate event names are stored once.
The secret is shown once. Store it now. You will need it to verify a signature, and the server keeps no copy it can show you again. If you lose it, rotate.
All of /webhooks needs webhooks:write, the reads included. Listing subscriptions enumerates every channel out of the account, so there is no read-only half. See scopes.
Events
There are 39 events you can subscribe to, one table of rows each (data.object), and the read that resolves data.id:
data.object |
events | read it with |
|---|---|---|
todo |
todo.created, todo.updated, todo.deleted |
GET /v1/todos/{id} |
note |
note.created, note.updated, note.deleted |
GET /v1/notes/{id} |
note_folder |
note_folder.created, note_folder.updated, note_folder.deleted |
GET /v1/note-folders/{id} |
note_link |
note.linked, note.unlinked |
GET /v1/note-links/{id} (a link between a note and a to-do) |
note_place |
note.place_added, note.place_removed |
GET /v1/note-places/{id} (a note’s place) |
note_note_link |
note.note_linked, note.note_unlinked |
GET /v1/note-note-links/{id} (a link between two notes) |
note_version |
note_version.created, note_version.updated, note_version.deleted |
GET /v1/note-versions/{id} (gives noteId; the text is at GET /v1/notes/{noteId}/versions/{id}) |
container |
container.created, container.updated, container.deleted |
GET /v1/containers/{id} |
heading |
heading.created, heading.updated, heading.deleted |
GET /v1/headings/{id} |
inbox |
inbox.created, inbox.updated, inbox.deleted |
GET /v1/inbox/{id} |
tag |
tag.created, tag.updated, tag.deleted |
GET /v1/tags/{id} (308 when the id was renamed away) |
filter |
filter.created, filter.updated, filter.deleted |
GET /v1/filters/{id} |
attachment |
attachment.created, attachment.updated, attachment.deleted |
GET /v1/attachments/{id} |
attachment_place |
attachment_place.created, attachment_place.updated, attachment_place.deleted |
GET /v1/attachment-places/{id} (an attachment’s extra place) |
In words: todo.*, container.*, heading.*, note.*, note_folder.*, note_version.*, inbox.*, tag.*, filter.*, attachment.* and attachment_place.*, each with .created, .updated and .deleted. A note also sends note.linked and note.unlinked when it is linked to a to-do or unlinked, note.note_linked and note.note_unlinked for a link with another note, and note.place_added and note.place_removed when an area, a project or a subproject is added to its places or taken out. You choose these in events.
An attachment can be in several places. Each place after its first is a row of its own: putting an attachment in another place is attachment_place.created, reordering it there is attachment_place.updated, and taking it out is attachment_place.deleted. data.object is attachment_place and data.id is that row's id: GET /v1/attachment-places/{id} gives the attachmentId and the place while it exists. When the first place goes and the next one takes over, the attachment itself changes too (attachment.updated). See attachments.
A to-do's time, length, reminders and subtasks are columns of the to-do: a change to any of them is a todo.updated.
A heading is a named group of to-dos inside a project. When a to-do is filed under one, taken out, or loses its heading because the heading was deleted, that is a todo.updated: read the to-do's headingId. heading.deleted does not list the to-dos the heading held (the server cannot read them when an app deletes it); each of them sends its own todo.updated.
For note.linked and note.unlinked, data.object is note_link and data.id is the link's id: GET /v1/note-links/{id} gives its noteId and todoId while it exists. After note.unlinked it is gone, so read the note's links instead. For note.place_added and note.place_removed, data.object is note_place and GET /v1/note-places/{id} gives the noteId and the containerId while the place exists. For note.note_linked and note.note_unlinked, data.object is note_note_link and GET /v1/note-note-links/{id} gives the two noteIds. A note's version history sends note_version.*, with the version id: GET /v1/note-versions/{id} gives its noteId, and the text is at GET /v1/notes/{noteId}/versions/{id}. Restoring a folder from the trash is a note_folder.updated: the server cannot read trashedAt, so there is no restored event (nor a todo.completed, for the same reason).
Two more are sent to every subscription, whether events lists them or not:
webhook.ping, when you callPOST /v1/webhooks/{id}/ping;webhook.disabled, when the server turns off another subscription of yours. See delivery.
events may name them, and naming them changes nothing.
Deleting a capture, in an app or with POST /v1/inbox/{id}/delete or /archive, archives it: that is inbox.updated, and the row stays readable. inbox.deleted is sent only when a capture row is removed outright, which no app or API call does in normal use.
What an event carries
id: one per change. If two of your subscriptions hear about the same change, both get the sameid, and a redelivery keeps it. De-duplicate on it.type: the event name.occurredAt: when the change reached the API, usually within a second of the write.data.objectanddata.id: what changed. Fetch it with the API.data.sub: who it happened to, as a pseudonymous id. It is never the account's internal user id.
For a subscription made with an access key or a connected app, data.sub is the sub that credential reads on /me, so you can join the two. For a subscription made in an init.Tasks app, it is an id of that subscription's own: the same on every event it receives, and unrelated to anything else.
Events are per row, not per list. Sorting a to-do out of the inbox, scheduling it, filing it under an area or moving it between lists all arrive as todo.updated: fetch the to-do and read sorted, whenDate and parentId to see what changed. A to-do, note or attachment under an area emits exactly the events it would under a project. There are no per-list events, so nothing depends on a view's name.
A file added in an app before its upload finished arrives as attachment.created (or inbox.created); fetch the row and it reads pending: true. When the device that added it has uploaded the bytes, an .updated event follows: fetch the row again and pending is false, with fileId set on an attachment.
There is no todo.completed
status is encrypted. The server cannot tell a completion from a rename, so both arrive as todo.updated.
You do the comparison. Keep the last status you saw for each id, fetch the object when an update arrives, and compare. A to-do's status is open, someday or done. Completion is done, and there is no completed value.
// This event does not exist. Do not wait for it.
// if (event.type === 'todo.completed') { ... }
const seen = new Map(); // id -> status
async function onEvent(event) {
if (event.type !== 'todo.updated') return;
const res = await fetch(`https://api.inittasks.com/v1/todos/${event.data.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 404) { seen.delete(event.data.id); return; }
const todo = await res.json();
const before = seen.get(todo.id);
seen.set(todo.id, todo.status);
if (before !== 'done' && todo.status === 'done') {
await celebrate(todo);
}
}You would need this shape anyway, because events can arrive out of order.
A tag rename
Events stay thin for tags too: an event never carries a tag's name, old or new, and there is no rename event and no merge flag. The server cannot read tag names, so it could not fill them in.
A rename, from the app or from PATCH /v1/tags/{id}, arrives as the ordinary row events it causes. They are delivered in parallel, so they can arrive in any order:
tag.createdfor the tag at its new id, ortag.updatedif a tag already sat there (a merge, or an old name coming back);tag.updatedfor the old id, which is now a forwarding pointer, and for any older id that forwarded to it;- one
todo.updatedfor each to-do that carried the tag, oneattachment.updatedfor each attachment and onenote.updatedfor each note that carried it, and onefilter.updatedfor each filter that named it. There is no batching, the same as when a tag is deleted.
To find out where an old id went, ask for it. This runs on a server (Node 18 or later): in a browser, redirect: 'manual' hides the 308.
async function onTagUpdated(event) {
const res = await fetch(`https://api.inittasks.com/v1/tags/${event.data.id}`, {
headers: { Authorization: `Bearer ${token}` },
redirect: 'manual',
});
if (res.status === 308) {
const target = await res.json(); // the tag it became
renameInMyCache(event.data.id, target.id, target.name);
} else if (res.status === 200) {
const tag = await res.json(); // a live tag: a colour or a name change
updateMyCache(tag);
} else if (res.status === 404) {
dropFromMyCache(event.data.id);
}
}A tag.updated for an id you have never seen, together with a burst of todo.updated, is what a rename looks like from outside. See tags.
Is the event stream alive
GET /healthz is unauthenticated and carries webhookSource, which says whether events are reaching the API at all. Deliveries to you are downstream of it: if this is not ok, no webhook of yours is firing, however healthy your own endpoint is.
| value | what it means |
|---|---|
ok |
an event arrived within the last 24 hours |
missing |
the API changed a row more than two minutes ago and nothing has come back since. The event stream is broken, right now |
stale |
nothing for 24 hours, with no recent change of ours to compare against. Usually a quiet account |
unknown |
no event has ever arrived. A freshly deployed host looks like this |
An alarm reaches the operator when this turns missing, and again when the API refuses a delivery it should have accepted. You do not have to watch this field for us.
missing is the one to act on. Every row the API writes makes the upstream database emit an event, so a write of ours with no event after it cannot happen while the stream is intact. It is a statement about the source, not about your subscription, and it is the difference between a two-minute alarm and a day of silence that reads like a slow week.
{ "ok": true, "version": "1.0.0", "buildId": "…", "webhookSource": "ok" }(Abridged: /healthz carries a few more operational members. webhookSource is the one for you.)
A subscription of yours can be disabled separately, after three days of failed deliveries to your URL. That shows up as webhooks.disabled on /me, which counts only the subscriptions the server turned off (not the ones you paused), and as a webhook.disabled event to your other subscriptions. Not here.
How long a subscription lives
- Until you
DELETEit. - A subscription made with an access key or a connected app is deleted, with its delivery log, when that key or connection ends: revoking the key, disconnecting the connection, signing its session out from the sessions list (
DELETE /v1/sessions/{id}), or changing the encryption key (which revokes every key and connection). Every init.Tasks app, on iOS, Mac, Android and the web, does all three through the API (DELETE /v1/keys/{id}orDELETE /v1/grants/{id}). The one exception: if that API call fails during a key change, the app still revokes the credential directly so the key change completes, and the credential's subscriptions are then left behind. Delete those yourself. - A password change does not end a key or a connection, so their subscriptions keep delivering. They follow their key or connection: they live while it lives, and are deleted when it is revoked, replaced or ended by a key change.
- A subscription made in an init.Tasks app belongs to the account and is kept until it is deleted.
- Deleting the account deletes every subscription, with its delivery log. Signing out does not. The account's rows are deleted first, so
*.deletedevents for them may reach your endpoints in the seconds before.
Next
- Verify a signature before you trust a delivery.
- Delivery and reconciliation for retries, and for why a poll is still required.