Webhooks
Register an HTTPS endpoint and Soleil Clusters posts to it when boards, cards, uploads, comments and tags change — including changes made by people working in the app, not only changes made through the API. Every delivery is signed with HMAC-SHA256 over the timestamp and body, retried with exponential backoff for over twelve hours, and recorded in a delivery log you can inspect and replay.
Register an HTTPS endpoint and we post to it when something changes.
Including changes made in the app. That is the part worth stating plainly: events come from the database, not from the API request handler, so a card someone drags on the canvas fires the same event as one added by your importer. A webhook that only saw API traffic would miss almost everything that actually happens to a board.
Register one
Only the workspace's owner can see, add, change, replay or delete its webhooks and their deliveries — a webhook carries every event in the workspace, so where those go is the owner's call. When the workspace passes to a new owner, the previous owner's webhooks are turned off, and the new owner sets up their own. Registering a webhook, or pointing one at a new URL, opens up once the account is 72 hours old.
curl -X POST "$SOLEIL_API/webhooks" \
-H "Authorization: Bearer $SOLEIL_TOKEN" -H "Content-Type: application/json" \
-d '{"workspace_id":"'$WS'","url":"https://hooks.example.com/soleil",
"events":["card.created","card.updated","board.created"],
"name":"pipeline sync"}'{ "webhook": { "id": "a2c4…", "url": "https://hooks.example.com/soleil",
"events": ["card.created","card.updated","board.created"], "active": true },
"secret": "whsec_…",
"next": "Verify each delivery: HMAC-SHA256 of \"v0:{timestamp}:{body}\", compared with the X-Soleil-Signature header." }secret is returned once and never again. There is no read path for it, over the API or in the database.
The URL must be public HTTPS on the default port. We fetch it from our own address on a schedule, which is the shape of a server-side request forgery, so private ranges, loopback, metadata hosts and non-default ports are refused.
Pass "events": ["*"] to subscribe to everything, including event types added later. An unknown event name is a 400 rather than a subscription that never fires — "subscribed and silent" is the hardest webhook failure to diagnose.
Events
| Event | When |
|---|---|
board.created | A board is created |
board.updated | Its name, view or parent changes |
board.deleted | Soft-deleted, or removed outright |
board.restored | Brought back from the trash |
card.created | Cards are added to a board |
card.updated | Their text or contents change |
card.deleted | Cards are removed |
card.moved | Cards move to another board |
image.created | An upload completes: a picture, video, audio file or document |
comment.created | A comment or a reply is posted on a board |
comment.updated | Its words change |
comment.resolved | A thread is resolved |
comment.reopened | A resolved thread is opened again |
comment.deleted | A comment is deleted (it can still be restored) |
comment.restored | A deleted comment is brought back |
tag.applied | A tag is put on cards, groups, boards or doc pages |
tag.removed | A tag is taken off them |
Card, upload and tag events are grouped. A single operation on a board produces one event carrying a count, not one per card — so importing a thousand cards is one delivery, not a thousand. The ids come with it, the first 25 as a courtesy; past that, read the board.
Comments on the canvas fire comment events. The comments inside a doc or a note live in the doc itself, so they fire none: read them with GET /boards/:id/comments.
The payload
{
"type": "card.created",
"resource": { "type": "card", "id": "9f1c…" },
"workspace": { "id": "3b7e…" },
"board": { "id": "9f1c…" },
"data": { "count": 412, "card_ids": ["…", "…"] },
"occurred_at": "2026-08-09T12:00:00.412Z"
}Deliberately thin. A payload is a copy of the truth, and a copy goes stale between being sent and being read — worse, it goes stale silently. You get enough to know what to look at; call back for current state, with ?since= if you want everything that moved.
resource.id is the comment's id for a comment event and the tag's id for a tag event. A grouped card or upload event covers several things at once, so its resource.id is the board's.
What data holds
| Events | data |
|---|---|
card.created, card.updated, card.deleted | count, card_ids |
card.moved | count, card_ids (as they are now, on board), from_board_id, from_card_ids (the same cards' ids where they were, in the same order: a card whose id was taken on arrival gets a new one) |
image.created | count, image_keys (each one's key for GET /images/:key); board is null for an upload that isn't on a board |
comment.* | id, thread_id, reply, on (card, group, point or board, or doc with a page_id for a comment made on a doc before its comments moved into the doc), card_id or group_id where it is pinned, author_id, resolved |
tag.applied, tag.removed | id (the tag's), auto (true when automatic tagging put it on or took it off), count, items: each thing it is on, {kind, id} with kind one of card, group, board or doc_page (a doc page adds page_id) |
A comment's words are not in its event. Read them through the API, under the permissions of the person whose token you use.
A move between two workspaces fires card.moved in both, so the workspace the cards left hears of it too. Merging two tags reads as tag.removed for the tag merged away and tag.applied for the one it merged into. Confirming a tag that automatic tagging put on changes no tag, so it fires nothing.
Verifying a delivery
| Header | |
|---|---|
X-Soleil-Signature | v0=<hex> |
X-Soleil-Request-Timestamp | Unix seconds |
X-Soleil-Event | The event type |
X-Soleil-Delivery | This delivery's id — useful in your own logs |
Compute HMAC-SHA256(secret, "v0:" + timestamp + ":" + rawBody) and compare it with the signature. Use the raw body, before any JSON parsing — re-serializing changes the bytes and the signature will not match.
import hmac, hashlib, time
def verify(secret, headers, raw_body):
ts = headers["X-Soleil-Request-Timestamp"]
if abs(time.time() - int(ts)) > 300: # five-minute replay window
return False
expected = "v0=" + hmac.new(
secret.encode(), f"v0:{ts}:".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, headers["X-Soleil-Signature"])This is the same scheme Frame.io uses, on purpose — if you have integrated with them, the verifier you already wrote works here.
Reject anything outside the five-minute window, and compare in constant time.
Retries
Six attempts: after 1 minute, 5 minutes, 25 minutes, ~2 hours and ~10 hours. That is over twelve hours in total, so an endpoint that is down overnight still receives its events.
Any non-2xx, or a connection failure, counts as a failure. Redirects are never followed: a 3xx is a failure too, so point the webhook at its final address. Respond 2xx as soon as you have durably accepted the delivery and do your work afterwards — a slow receiver is a retried receiver. We give up on a single delivery after ten seconds.
After 20 consecutive failures across all deliveries a webhook is switched off, with disabled_reason saying why. A single success resets the counter, so this only ever fires for a genuinely dead endpoint. Re-enable with PATCH /webhooks/:id {"active": true}, which also clears the failure state.
The address is checked again at every delivery, not only when the webhook is saved. One that no longer passes the public-host rule — say it was saved before the rule was tightened — is never posted to: it is switched off at once, with the reason in disabled_reason.
The delivery log
curl "$SOLEIL_API/webhooks/$HOOK/deliveries?limit=50" -H "Authorization: Bearer $SOLEIL_TOKEN"Every attempt, with its status, duration, error and the exact payload sent. "We sent it" without a record is not an answer, so there is a record.
Newest first, up to 500 per page. While has_more is true, pass next_cursor back as cursor for the next page. The cursor carries the delivery's id as well as its time, because one change fans out to many deliveries created in the same instant, and a time alone would skip the rest of them.
# Send a real delivery through the real path, to check signing end to end
curl -X POST "$SOLEIL_API/webhooks/$HOOK/test" -H "Authorization: Bearer $SOLEIL_TOKEN"
# Replay one you missed
curl -X POST "$SOLEIL_API/webhooks/$HOOK/deliveries/$DELIVERY/redeliver" \
-H "Authorization: Bearer $SOLEIL_TOKEN"POST /webhooks/:id/test sends through the delivery path everything else uses, so what it proves is what will actually happen. It sends that one delivery straight away and answers with what happened to it:
{ "sent": true,
"delivery": { "id": "…", "delivered": true, "status": 200, "attempt": 1,
"response_ms": 84, "error": null } }"sent": false carries your receiver's status or the connection error in delivery.error. If another delivery run already has it in hand, delivery says "queued": true: it is on its way, and its result appears in the delivery log.
POST /webhooks/:id/deliveries/:deliveryId/redeliver puts the delivery back in the queue and sends it at once through the same path, answering with the same delivery object. A replay that fails is retried and recorded exactly like any other delivery.
Each change reaches each webhook as one delivery, even when several delivery runs overlap. A receiver that times out or answers non-2xx gets the same delivery again on retry, under the same X-Soleil-Delivery id, so de-duplicate on that.
Deliveries are kept for 30 days.
Managing them
curl "$SOLEIL_API/webhooks?workspace=$WS" -H "Authorization: Bearer $SOLEIL_TOKEN"
curl -X PATCH "$SOLEIL_API/webhooks/$HOOK" -H "Authorization: Bearer $SOLEIL_TOKEN" \
-H "Content-Type: application/json" -d '{"events":["*"],"active":true}'
curl -X DELETE "$SOLEIL_API/webhooks/$HOOK" -H "Authorization: Bearer $SOLEIL_TOKEN"Up to 20 active webhooks per workspace. Listing never returns the secret.
Timing
An event caused by an API call is delivered immediately. An event caused by someone working in the app is picked up within a minute. Both go through the same queue and the same retry policy; only the pickup differs.
Frequently asked questions
Do webhooks fire for changes made in the app?
Yes. That is the main point. Events come from the database itself, so a card someone drags on the canvas fires the same event as one added through the API.
How do I verify a delivery is really from you?
HMAC-SHA256 of "v0:{timestamp}:{body}" using the secret from the create response, compared with the X-Soleil-Signature header. Reject anything with a timestamp more than five minutes old.
Does a thousand-card import send a thousand webhooks?
No. Card events are grouped per board per operation, so that batch is one delivery carrying a count.
What if my endpoint is down?
Six attempts over more than twelve hours. Every attempt is recorded, and you can replay any delivery from the log once you are back.
Why is there so little in the payload?
A payload is a copy, and a copy goes stale. You get the type and the ids; call back for current state.
Machine-readable: /docs/api/webhooks.md · /llms.txt
What changed and when: /changelog