Skip to main content
Webhooks push events to your own endpoint instead of making you poll. Each delivery is a signed POST with a JSON body.

Managing endpoints

Manage endpoints in the dashboard, or from your own code with an API key: Both doors take the same calls. On the /v1 door, creating and deleting take the subscribe verb’s shape (below); the dashboard keeps the endpoint record: An org can have at most 20 endpoints.

Endpoint fields

Every response also carries what we keep about the endpoint, among them:

Subscribing from an agent

POST /v1/webhooks is the MCP subscribe verb: {events, url, workspace?}. events is required (GET /v1/webhooks/events lists them), url is the https endpoint, and workspace scopes the endpoint to one workspace (the key’s default when omitted; another organisation’s is a 404). Any other field is a 400: set description or enabled afterwards with PATCH /v1/webhooks/{id}.
Shell
Response
The response to the create call is the only time the signing secret is shown in full. Store it then; every later read shows it masked. An OAuth connection (an MCP client a person connected) is never handed a signing secret. Its subscribe is a 403 with details.confirm_url, a link to Settings → Webhooks with the URL filled in, where a signed-in person creates the endpoint and sees its secret. Nothing is created until they do. An MCP client that only wants to follow events can subscribe to resource updates instead, which needs no url and no secret. DELETE /v1/webhooks/{subscription_id} ends the subscription. It needs a key with the configure scope; a connection does not hold that scope, so a connection cannot end one: a key or a person in Settings → Webhooks does. An endpoint also reports how deliveries are going: last_delivery_at, last_status, last_error and failing_since. If we turned it off, disabled_reason and disabled_at say why and when (see When an endpoint is turned off).

Events

GET /v1/webhooks/events returns the same list, each with a one-line description, so your code can read what it may subscribe to. New event types are added at the end; a receiver should ignore a type it does not know. work_item.created. kind is the shape of the work, such as fix, rewrite, document, social_post, brand_book_edit (an edit to your Brand Book), answer or outreach. A company profile edit an assistant or an agent proposes is a Brand Book edit, so it reports brand_book_edit, where it used to report document. target is where approving it delivers, such as github, notion, webflow, copy (you publish it yourself) or email, and is empty when approving it delivers nowhere on its own. document.created. Sent once per version of a document that became work: the agent revising an article into a new version sends it again, and a document kept beside a chat is not sent at all. target_plugin is where approving it delivers: github, notion, a CMS such as webflow, or copy. body_markdown is the whole document, up to 256 KB. Past that it is null and body_truncated is true, rather than a document cut off mid-sentence; dashboard_url opens the chat that wrote it, where the whole document is. session_id and work_item_id may be null. change.shipped. Sent once per change, however it went live: its pull request merged, an integration published it, or someone marked it shipped. url is where it can be opened and delivery_label says where it lives in words, such as “Pull request #17 on GitHub” or “Draft in Webflow”; either may be null. A change that landed as a draft in your own tool is not shipped until it is marked shipped. change.no_longer_live. Sent only on a read that came back with the page: a page we could not read never sends it. url is the page read, checked_at when, finding the sentence the receipt shows, and reship_change_id the change filed to put the text back (it ships through your usual approval and veto). Sent once per change for each week it is found missing; a change still missing the week after sends nothing more until it is back and lost again. change.repaired and change.withdrawn. repair_attempt is the attempt as GET /v1/changes/{id} lists it in repair_attempts: id, at, trigger, action, reason, check and pull_request. change.repaired is sent once per attempt that put the change back on its way (redrafted or regenerated); ships_at is the fresh end of its veto window when it got one, else null. A blocked change sends no event: it files an Inbox row, which sends needs_you. change.withdrawn carries reason already answered and the questions it was for, and is sent once per change. goal.updated. change says what happened: created, updated, pace_changed, hit, missed, paused, resumed or archived. pace_state is ahead, on_pace, behind or unmeasured, and previous_pace_state is present only when the pace moved. current is {value, n}, with value null until the first reading. A daily reading that changes neither pace nor status sends nothing. needs_you. Sent once for each thing that starts waiting on you in the Inbox’s “Needs you” list. item_type says which:
  • ask: a run stopped and parked on an approval (it wants a yes or no before a step it cannot undo) or on questions. Sent once per ask, when the run parks. kind is approval or questions, and ask_kind is approve or answer. cause is the run’s “Because…” sentence, and message is the gate’s reason or the first question.
  • task: a run filed an Inbox row it could not get past alone. Sent once per row, when it is filed. A run that hits the same problem again adds to the open row instead of filing a new one, so it sends nothing more. If the row is closed and the problem comes back, the new row sends a new event. A notice (nothing is asked), a row filed snoozed, and a row folded into another are not sent. kind is the row’s kind (blocked, decision, question, capability_gap, run_failed, ask_expired or rewrite_unsent), ask_kind is answer (type a reply in the row) or do (take a step, then press Done), and cause is why it was filed (for example missing_integration).
A proposed change waiting for your approval is sent as work_item.created when it is filed, not as needs_you. A snoozed row that comes back into “Needs you” when its snooze ends sends nothing. item_id is the item’s Inbox id, uri names it on every door (attensira://{workspace}/ask/{item_id}), and dashboard_url opens it in the Inbox. cause, message, automation_id and work_area_id may be null, and so may session_id on a task. It is queued as the item starts waiting, so it normally arrives within seconds. loop.blocked. Loop health is checked once an hour. A loop found blocked starts a clock at blocked_since; a check that finds it unblocked stops it, and the next block starts a new one. The event is sent once per blocked stretch, at the first hourly check 12 hours or more after blocked_since, so it arrives within an hour of the 12-hour mark. reasons is a list of {code, owner, action}: code is a loop health reason code (for example vendor_incident, no_credits, connections_reconnect, no_readings, runs_failing), owner is product (ours to fix: you are not emailed) or customer (yours: the workspace owner gets one email with one action), and action is reconnect, top_up or review for a customer-owned code and null for ours. The payload carries codes, not sentences, and never names one of our providers. rival.gained. Sent with the rival alert, in the hourly detection pass that found the gain: at most once per rival, prompt and engine in seven days, and only for a gain that was measured and stood up (see Noise is held back). days_named of the last recent_days days named the rival, and none of the prior_days_read earlier days we read did; n is the answers that named it. quote is the engine’s own words, with ... where it was cut from a longer answer, or null when no answer could be quoted honestly, and prompt is null if the prompt’s text could not be read. sentence is the alert as we sent it. counter_url is where Counter it is posted for this alert. loop.recovered. Sent once, at the first hourly check that finds a loop announced by loop.blocked running again. blocked_since names the stretch it ends. A stretch that ended before 12 hours sends neither event. Payloads link to the dashboard and to where the work lives. They never carry a link that approves or merges anything: that happens where a person reads it first.

A delivery

Each delivery is a POST to your URL with a JSON body:
Body
With these headers: The body’s id is the event id: the same for every delivery of the event, retries and resends alike. Dedupe on it.

Verifying the signature

v1 is the hex HMAC-SHA256, keyed with the endpoint’s signing secret, of the string <t>.<body>: the t value, a full stop, and the raw request body exactly as received.
  1. Split the header on , and read t and every v1.
  2. Compute the HMAC over t + "." + rawBody.
  3. Compare it with each v1 in constant time. Accept the delivery if any one matches.
  4. Reject the delivery if t is more than 5 minutes old.
Node

Retries

Answer with any 2xx to accept a delivery. Any other status, or no answer within 10 seconds, is retried up to 14 times with exponential backoff, spread over about a day. Redirects are not followed: a 3xx counts as a failure. A 410 Gone is not retried: it turns the endpoint off. Because a retry carries the same Attensira-Delivery and the same event id, a receiver that stores the event ids it has handled can treat a repeat as already done. One occurrence, one id. Events that name a single occurrence (a work item filed, a document version, a change shipped, a report, a skipped run, a goal transition) get an id derived from it. If the same occurrence is reported to us twice, for example GitHub redelivering a merge, it renders the same id, and is not queued again while the first delivery is still held (up to a day after it finishes). Past that it may be delivered again under the same id, which is why deduping on the event id is the reliable rule.

The delivery log

Every delivery to an endpoint is logged for 30 days: what was sent and each attempt to send it. Read it in Settings → Webhooks under the endpoint’s Delivery log, or with:
Shell
Response
  • id is the event id: the body’s id, and the Attensira-Delivery of the first send.
  • state is queued until the first attempt, then delivered or failed according to the latest attempt.
  • Each attempt carries its delivery_id: the Attensira-Delivery header it went out under. That is the event id for the original send and its retries, and the resend’s own id for a resend, so a row in your receiver’s log matches an attempt here.
  • Each attempt records when it was made, the status your server answered (0 when it did not answer), how long it took, and the first 2 KB of your server’s answer. Anything in that answer that looks like a secret, a token, a password or an email address is masked before it is stored. resend is 0 for the original send and n for the nth resend. A row keeps its latest 20 attempts.
  • Pages are newest first. limit defaults to 20, up to 100; pass next_cursor back as cursor for the next, older page. It is null on the last page.

Resending a delivery

POST /v1/webhooks/{id}/deliveries/{deliveryId}/resend, or Resend in the delivery log, queues a logged delivery again. {deliveryId} is the log row’s id. The resend is a new delivery of the same event: the same body (so the same event id), signed afresh with a new t, under a new Attensira-Delivery id. A receiver that dedupes on the event id treats it as done; one that lost it handles it now. A resend is never refused for being too soon after the original. Its attempts are added to the same log row. The resend’s own Attensira-Delivery id is the delivery_id in this response.
Response
A resend is refused with 409 while the endpoint is turned off, and with 404 once the delivery is older than the 30 days the log keeps.

Rotating the signing secret

Before you rotate, make sure your verifier accepts any v1 in the header, as the snippet above does. A verifier that reads only one v1 fails every delivery for the 24 hours both secrets sign.
POST /v1/webhooks/{id}/rotate, with an API key that has the configure scope, mints a new signing secret. Like every /v1/webhooks management route it takes an API key, not an OAuth connection: it hands back a secret. The response is the endpoint with the new secret in full; it is never shown again. The old secret keeps working for 24 hours, until previous_secret_expires_at. During that time every delivery’s Attensira-Signature carries two v1 values, one per secret, the new one first, so your server verifies whichever secret it has. Deploy the new secret within the 24 hours; after that only the new one signs.
Shell
Rotating again before the 24 hours are up retires the oldest secret at once: at most two ever sign. Two rotations at the same moment: one wins and the other answers 409.

When an endpoint is turned off

We stop sending to an endpoint, and turn it off, when:
  • your server answers 410 Gone, which says the endpoint no longer exists; or
  • a delivery fails and every delivery has failed for more than 72 hours.
Turning it off sets enabled to false, records disabled_reason and disabled_at, and raises one Inbox row, “Your webhook to <host> was turned off”, in the workspace the event was about (or the endpoint’s workspace, or your oldest workspace for an org-wide endpoint). Deliveries already queued for it are logged as not sent. The endpoint also carries a one-sentence fix (in GET /v1/webhooks, and as the Inbox row’s step) that says how to bring it back without the app. Nothing more is sent until someone turns it back on, in Settings → Webhooks or with PATCH /v1/webhooks/{id} and {"enabled": true}. That clears disabled_reason, disabled_at and failing_since, so the 72-hour clock starts again. Then resend what it missed from its delivery log. A test delivery is sent even while the endpoint is off, so you can check your server before turning it back on.