Webhooks
Your Inbox tells your systems what happened, the moment it happens. A new booking, an order that was paid, a customer’s reply: each one is an event, and an event is an HTTP POST to a URL you own, signed so you can prove it came from your Inbox and retried for a day if your server is down.
There is no platform to join, no OAuth dance and no app to register. You register a URL with one POST — or ask your AI to — keep the secret you are shown once, and answer 2xx.
What an event is, and when one is sent
Section titled “What an event is, and when one is sent”An event is one thing that happened to one item. Items are the five kinds of thing an Inbox holds — message, quote_request, booking, order and refund (Concepts) — and every move an item makes is an event: the moment it is created, every transition of its state machine, a flag being changed, and every message a customer sends on it.
Events are emitted for what the Inbox itself records, whoever caused it: a customer’s agent calling create_booking, you pressing Confirm in the app, your AI firing a transition through the owner MCP, a rule expiring a quote at midnight. An event is sent after the write has committed, so by the time you receive one, GET /v1/owner/items/{id} already answers with the new state.
Sandbox items (Concepts) produce events too — every event says so in data.sandbox — so you can build against a test item without touching anything real.
Every event also says who caused it and through which door: data.actor is {kind, id, name}, where kind is owner, owner_ai (your AI, over OAuth), integration (a key you made for another system), connector, rule, system, customer_agent or customer_human, id is the user, AI app or key id, and name is the key’s or AI app’s name; data.channel is the door (rest, mcp_owner, mcp_public, email, owner_ui, …). A customer’s id is never sent. A two-way sync should skip the events whose data.actor.id is its own key’s id: those are its own writes coming back, and acting on them again is how two systems end up in a loop.
The event types
Section titled “The event types”The type is <item type>.<event>. Subscribe to the exact types you want, to a whole item type with booking.*, or to everything with *, which is the default. An endpoint that subscribes to nothing receives nothing: there is no implicit “all”.
| Type | Sent when |
|---|---|
booking.create |
Someone requested a booking. |
booking.request_info |
You asked the customer for more details. |
booking.provide_info |
The customer answered with the details. |
booking.propose |
You proposed another time. |
booking.accept |
The customer accepted the time you proposed. |
booking.confirm |
You confirmed the booking; the slot is claimed. |
booking.decline |
You declined the request. |
booking.cancel |
The customer cancelled: before it was confirmed, or within your cancellation window. |
booking.cancel_late |
The customer cancelled a confirmed booking after your cancellation window, and you record late cancellations (booking.lateCancellation). |
booking.cancel_by_business |
You cancelled the booking. |
booking.expire |
A rule expired a booking nobody answered. |
booking.complete |
The booking happened: you marked it, or the system did booking.autoCompleteHours after the end (data.actor.kind is system); also your correction of a no-show. |
booking.no_show |
The customer did not turn up; also your correction of a completion. |
booking.receipt_issued |
The instance signed a receipt: the confirmation, or how the booking ended (Receipts). In the full style, data.receipt carries it. |
booking.receipt_acknowledged |
The customer’s agent counter-signed that receipt. |
order.create |
An order arrived. |
order.request_info |
You asked the customer for more details. |
order.provide_info |
The customer answered. |
order.accept |
You accepted the order. |
order.request_payment |
You asked for payment, optionally with a payment URL. |
order.record_payment |
A payment was recorded against the order. |
order.payment_failed |
The payment failed. The order can still be paid, or cancelled. |
order.lapse |
Payment was requested orders.payDays ago and never came: the system closed the promise for the networks; the order itself stays as it was, so a late payment is still taken. |
order.start_fulfilment |
You started putting the order together. |
order.fulfil |
The order went out. |
order.complete |
The order is closed and done. |
order.decline |
You declined the order. |
order.cancel |
The order was cancelled. |
order.charge_back |
The payment was reversed by the bank, and the order ended there. |
order.record_charge_back |
A charge-back was recorded on an order that was already completed. |
order.receipt_issued |
The instance signed a receipt: the acceptance, the payment, or how the order ended. In the full style, data.receipt carries it. |
order.receipt_acknowledged |
The customer’s agent counter-signed that receipt. |
quote_request.create |
Someone asked for a price. |
quote_request.request_info |
You asked what exactly they need. |
quote_request.provide_info |
They told you. |
quote_request.quote |
You sent a quote, with a total and a validity date. |
quote_request.accept |
The customer accepted the quote; a booking or an order follows. |
quote_request.decline |
The quote was declined. |
quote_request.expire |
The quote passed its validity date. |
message.create |
A new conversation started. |
message.answer |
You replied. |
message.close |
The conversation was closed. |
message.reopen |
It was reopened. |
message.mark_spam |
It was marked as spam. |
message.unspam |
It was not spam after all. |
refund.create |
A refund was requested. |
refund.approve |
You approved it. |
refund.reject |
You rejected it. |
refund.refund |
The money went back. |
<type>.message |
An inbound message arrived on an item of that type — booking.message, order.message, and so on. |
<type>.flags |
A flag changed on an item: needsHuman was raised or cleared, or its priority moved. |
One event is not about an item at all: inbox.test, what POST /v1/owner/webhooks/{id}/test sends. It carries "test": true, a sentence saying that nothing was created, and a data.id with no item behind it. A receiver that matches on the item type ignores it, which is the right behaviour — it is for whoever is checking the endpoint works, not for your integration.
Treat the list as open. New events appear as the state machines grow, so match the types you handle and ignore the rest rather than refusing what you do not recognise.
Add an endpoint
Section titled “Add an endpoint”One POST to the owner API:
curl -s -X POST https://your-inbox.example.com/v1/owner/webhooks \ -H "Authorization: Bearer sdi_own_…" -H "content-type: application/json" \ -d '{"url":"https://shop.example.com/hooks/inbox","events":["booking.*","order.*"],"payload_style":"thin"}'Four fields, and only the first is required:
url—httpsonly, and a public host. An IP address,localhostor an internal name is refused, which is also why a tunnel (ngrok,cloudflared) is the way to develop against a machine under your desk;events— the patterns you want;["*"], everything, is the default;payload_style— thin or full, see thin and full at the bottom of this page. Thin is the default and the right answer for almost everyone; full sends the customer’s data to that address;headers— up to five extra headers sent with every delivery, for a receiver that checks a header rather than the signature: n8n’s and Make’s webhook triggers, Pipedream, a gateway.{"Authorization": "Bearer …"}or{"Authorization": "Basic …"}is the usual one. The values are sealed like the signing secret and never shown again; the endpoint lists only the names.PATCHmerges: a name with a value sets it, a name withnullremoves it. Headers the delivery sets itself (content-type,user-agent,webhook-*,sdi-*and the connection’s own) are refused.
The answer is 201 with a Location header naming the new endpoint. Send an Idempotency-Key header and a retried request returns the same endpoint and the same secret instead of making a second one.
The signing secret is in that response and in no other: whsec_ and then base64, 32 random bytes. Copy it into your own configuration there and then. It is sealed in the database with your instance key, no endpoint and no tool will ever read it back, and if you lose it you rotate it, which shows you a new one. During a rotation both signatures travel on every delivery for 24 hours, so nothing is dropped while you deploy the new secret.
POST /v1/owner/webhooks/{id}/test then sends a real, signed, clearly marked test event, with the endpoint’s extra headers — its type is inbox.test, it carries "test": true, and no item exists behind it — and tells you the status your server answered. It is the fastest way to find out that a framework is redirecting you, or that a header check is refusing you. The owner app’s Send test button and the send_test_event tool do the same.
Your AI can do all of it without you writing any of that: on the owner MCP server the same operations are create_webhook, update_webhook, rotate_webhook_secret, delete_webhook, send_test_event, list_webhook_deliveries, replay_webhook_delivery, replay_missing_webhook_deliveries and list_events (Connect your AI). Ask it to connect your shop to your Inbox and it will.
The Integrations tab in the owner app, under Settings, does the same from a screen: add an endpoint, read the secret once, send a test and see the status it answered, pause, rotate, remove. It uses the same API above, so nothing is possible from the screen that is not possible from the API.
The request we send
Section titled “The request we send”A POST, content-type: application/json, three signature headers, and nothing else you need to care about:
POST /hooks/inbox HTTP/1.1host: shop.example.comcontent-type: application/jsonaccept: application/jsonuser-agent: surfingdog-inbox/0.0.0webhook-id: 01K5RJ3B4C5D6E7F8G9H0JKMNPwebhook-timestamp: 1789992000webhook-signature: v1,ceSnlptw5xQUh4NhglImizWi+wQ7rjsL2Dyjl4kXX9U=sdi-event-type: booking.createsdi-delivery-attempt: 1
{"id":"01K5RJ3B4C5D6E7F8G9H0JKMNP","type":"booking.create","timestamp":"2026-09-21T12:00:00.000Z","data":{"id":"01K5RJ2X9Y8Z7W6V5U4T3S2R1Q","type":"booking","state":"requested","version":1,"url":"https://inbox.example.com/v1/owner/items/01K5RJ2X9Y8Z7W6V5U4T3S2R1Q","actor":{"kind":"customer_agent","id":null},"channel":"mcp_public","sandbox":false}}The three webhook-* headers are the signature and are all you need. sdi-event-type and sdi-delivery-attempt are conveniences: route on the first without parsing the body, and log the second, which counts from 1, so a 3 in your logs tells you the first two attempts never landed.
That body, spaced out so you can read it, is a thin event:
{ "id": "01K5RJ3B4C5D6E7F8G9H0JKMNP", "type": "booking.create", "timestamp": "2026-09-21T12:00:00.000Z", "data": { "id": "01K5RJ2X9Y8Z7W6V5U4T3S2R1Q", "type": "booking", "state": "requested", "version": 1, "url": "https://inbox.example.com/v1/owner/items/01K5RJ2X9Y8Z7W6V5U4T3S2R1Q", "actor": { "kind": "customer_agent", "id": null }, "channel": "mcp_public", "sandbox": false }}webhook-id is the event’s id and it is stable across retries: deduplicate on it. On a transition event data.version is the item’s version after the event, so one that arrives out of order is one you can drop by comparing it with the version you hold. A <type>.message event does not bump the version — it reports the item’s version as it stands when we send — so order messages by webhook-id, which is a ULID, and never drop one on version.
Verify it
Section titled “Verify it”The signature is Standard Webhooks v1.0.0, which is what OpenAI, Anthropic, Twilio, Resend and Clerk already send, so an off-the-shelf library in any language verifies us. The rules, if you would rather write it yourself:
- the signed content is
{webhook-id}.{webhook-timestamp}.{body}, over the raw body bytes exactly as they arrived — re-serialising a parsed object changes the spacing and the key order, and the signature will not match; webhook-signaturecarries one or more space-separatedv1,<base64>values; any one matching is a pass, which is what lets a secret be rolled without dropping a delivery;webhook-timestampis Unix seconds and must be within five minutes of now, in both directions. Without that check, anyone who captures one request can replay it at you forever;- compare in constant time.
With our helper, which is MIT so it costs you no licence, and WebCrypto so it runs in Node, in a Worker, in Deno, in Bun and in a browser. No dependencies, 15 kB:
npm install @surfingdog/sdkThen:
import { verifyWebhook, WebhookVerificationError } from "@surfingdog/sdk";
export async function POST(request: Request) { const body = await request.text(); // the raw body — never request.json() try { const event = await verifyWebhook({ payload: body, headers: request.headers, secret: process.env.INBOX_WEBHOOK_SECRET!, }); await queue.push(event); // do the slow part after you have answered return new Response(null, { status: 204 }); } catch (error) { if (error instanceof WebhookVerificationError) { return new Response(error.code, { status: 400 }); } throw error; }}verifyWebhook takes the headers as a Headers, a plain object, a Map or a list of pairs, because your framework will hand you whichever it likes. It returns the parsed event, or throws a WebhookVerificationError whose code says what was wrong: missing_header, bad_timestamp, timestamp_too_old, timestamp_too_new, bad_signature_header, no_matching_signature, bad_secret, bad_payload.
With the standardwebhooks library, which you may already have, on Express:
const express = require("express");const { Webhook } = require("standardwebhooks");
const wh = new Webhook(process.env.INBOX_WEBHOOK_SECRET.replace(/^whsec_/, ""));
app.post("/hooks/inbox", express.raw({ type: "application/json" }), (req, res) => { let event; try { event = wh.verify(req.body, req.headers); // req.body is a Buffer: the raw bytes } catch { return res.sendStatus(400); } res.sendStatus(204); queue.push(event);});express.raw matters: express.json hands you an object, and an object cannot be verified.
In another language, any Standard Webhooks library works — Python, Go, Rust, PHP, Ruby, Java, C#, Elixir. Nothing about our deliveries is bespoke.
What to answer
Section titled “What to answer”Any 2xx, and answer it quickly. 204 No Content is the tidiest. Verify the signature, put the event on a queue or in a table, answer, and do the work afterwards; if you do the work first, a slow third-party API of your own turns into a failed delivery and a retry you did not need. A delivery times out after ten seconds.
Everything that is not 2xx is a failure — including a redirect. This is the commonest integration bug there is: a framework that quietly answers 301 from /hooks/inbox to /hooks/inbox/, or a proxy bouncing http to https, and the endpoint that “works in the browser” fails every delivery. We do not follow redirects, deliberately: a webhook URL that can be pointed somewhere else by its own answer is a server-side request forgery waiting to happen. Point us at the final URL.
A failed signature check is worth a 400. It will be retried and will fail again, which is exactly what you want in the log if someone really is posting forgeries at your endpoint.
Retries, and what happens at the end
Section titled “Retries, and what happens at the end”Eight attempts over about a day, each with a tenth of jitter so a fleet of endpoints coming back at once does not arrive in lockstep:
| Attempt | Sent |
|---|---|
| 1 | immediately |
| 2 | 5 seconds later |
| 3 | 5 minutes later |
| 4 | 30 minutes later |
| 5 | 2 hours later |
| 6 | 5 hours later |
| 7 | 10 hours later |
| 8 | 10 hours later |
The last attempt lands a little over a day after the event, which is long enough to cover a night of downtime nobody noticed. After it, the delivery is marked failed — and kept. An endpoint that has done nothing but fail for five days is deactivated, and it is kept too: never silently deleted, never quietly forgotten. GET /v1/owner/webhooks shows it, inactive, with disabled_at and the last error that did it, and your failed deliveries are still sitting there waiting to be sent once the address is fixed. (Some platforms drop a subscription after a handful of failures and tell nobody. That is the behaviour this is avoiding.)
Replay
Section titled “Replay”GET /v1/owner/webhooks/{id}/deliveries lists one endpoint’s recent deliveries with their status, response code, duration and last error; GET /v1/owner/deliveries does the same across every endpoint. Both are keyset paginated, newest first, and both are list_webhook_deliveries on the owner MCP.
Two ways to send something again:
-
one delivery,
POST /v1/owner/deliveries/{id}/replay, on the row it already has; -
everything the endpoint missed,
POST /v1/owner/webhooks/{id}/replaywith{"since":"2026-09-20T00:00:00Z"}, which queues every matching event from that instant that this endpoint never received. Turning a deactivated endpoint back on (PATCH /v1/owner/webhooks/{id}with{"active":true}) clears its failure run first, so run that, then this.One call scans at most 500 events. When the answer comes back
"truncated": trueit also carries anext_after: call it again with the samesinceand{"after":"<that value>"}to take the next window, and keep going untiltruncatedis false. Re-sending the same request withoutafterreads the same 500 events again and queues nothing new.
A replay is the same event, under the same webhook-id, with a fresh timestamp and signature. A thin transition event replays byte for byte. A full event — and a thin <type>.message event, because a message does not change the item — is rebuilt from the item as it stands now, so it may show a state later than the one the event announced. If you deduplicate on webhook-id, as you should, a replay of something you already handled costs you nothing.
Deliveries are pruned after thirty days. For anything older, use the cursor below — the events themselves are kept as long as their items are.
Thin and full
Section titled “Thin and full”A thin event carries a pointer: the ids, the type, the new state, the version, a URL, who caused it, the door and whether it is a sandbox item. A full event is the same envelope with more inside data: item, the whole item with its typed payload and its flags; transitions, the events it accepts right now, each with a label; human, a sentence describing it; party, the customer, with the name, email address and phone number you hold for them; and, on a <type>.message event, message, the message itself.
Thin is the default, for two reasons. A thin transition event never goes stale: if a delivery succeeds ten hours late, it still says “booking 01K5… changed, go and look”, whereas a full event would be telling you about a state that has moved on twice since. (A <type>.message event is the exception in both styles: a message does not change the item, so its state and version are the item’s as they stand when we send.) And a thin event does not copy a customer’s name, email address and phone number to a URL that somebody pasted into a form once, possibly into a no-code tool logging every request body.
Choose full when the receiver genuinely cannot call back — a Zapier or Make step that can only read what it is handed, or a Slack message that needs the customer’s name in it. Choose it knowing what it means: payload_style: "full" sends your customers’ names, email addresses and phone numbers to that address, on every event, for as long as the endpoint exists. To fetch an item from a thin event, GET the data.url with an owner API key (API); you get the same item, as it stands now.
If you cannot receive a webhook
Section titled “If you cannot receive a webhook”Poll instead. GET /v1/owner/events is the same stream, read forward from wherever you left off:
curl -s "https://your-inbox.example.com/v1/owner/events?cursor=01K5RJ3B4C5D6E7F8G9H0JKMNP&limit=100&types=booking.*,order.*" \ -H "Authorization: Bearer sdi_own_…"{ "events": [ { "id": "01K5RJ3B4C5D6E7F8G9H0JKMNQ", "type": "order.record_payment", "timestamp": "2026-09-21T12:04:11.318Z", "data": { "id": "01K5RJ2X9Y8Z7W6V5U4T3S2R1R", "type": "order", "state": "paid", "version": 4, "url": "https://your-inbox.example.com/v1/owner/items/01K5RJ2X9Y8Z7W6V5U4T3S2R1R" } } ], "next_cursor": "01K5RJ3B4C5D6E7F8G9H0JKMNQ"}Events come back oldest first, and the ids are ULIDs, so they sort in the order things happened. The stream trails live by a few seconds: an event’s id is minted a moment before its write commits, so holding the newest few seconds back is what lets you treat next_cursor as a hard watermark and never miss a row that landed out of order. Send the next_cursor of your last page as cursor on the next call; omit cursor to start at the beginning of time, and expect null when you have caught up. There is nothing to acknowledge, so re-reading from an older id is free — which is what makes this the way to backfill after an outage, or to build a copy of your data from scratch. limit is 1 to 100 and defaults to 50; types takes the same patterns a subscription does, comma-separated or repeated; since takes an ISO instant when you would rather start from a time than from an id.
Every event you would have received as a webhook appears here, as the same thin event, whether or not an endpoint exists. The owner MCP exposes it as list_events, so the AI you already connected can read the stream too.