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.
An email that gives you a way into an account you hold — a sign-in code or link, or a password reset, from the service’s own domain — is never an event, on a webhook or in the polling stream, and a key or your AI reading items never sees what it says: only a person at the business reads it, in the app. The same holds for a notice: an email from a system (a receipt, an account’s or a newsletter’s mail) that answers no conversation, kept under Notices.
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; or a customer accepted a quote for one, and it arrives already confirmed. |
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, or another one in place of the time you proposed before. |
booking.accept |
The customer accepted the time you proposed. |
booking.counter |
The customer asked for another time instead of the one you proposed; the booking is back with you, at that time. |
booking.retract |
You withdrew the time you proposed (it said it was subject to your confirmation); the request is back with you. |
booking.confirm |
You confirmed the booking; the slot is claimed. From proposed, at the time you proposed: the customer said yes to a person. |
booking.decline |
You declined the request. |
booking.cancel |
The customer cancelled: before it was confirmed (declining the time you proposed is one), 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.record_cancel |
You recorded a cancellation the customer asked for, by phone, email or in person: theirs, not yours. |
booking.record_cancel_late |
The same, when they asked after your cancellation window and you record late cancellations. |
booking.expire |
The request lapsed: nobody answered it in time (booking.autoExpireHours), or the customer did not answer the time you proposed by its deadline (data.actor.kind is system); or a rule expired it. |
booking.propose_change |
A change to the confirmed booking was asked for: by you (another time, a price of yours), or by the customer (another time); data.actor says who. It stays as agreed until the other side accepts. |
booking.accept_change |
The change was accepted: the booking moved to its new time, its places with it. |
booking.decline_change |
The change was not accepted: by you, or by the customer, who keeps what was agreed. The booking stays as it is. |
booking.retract_change |
Whoever asked for the change took it back (yours only when it said it was subject to your confirmation). The booking stays as it is. |
booking.expire_change |
Nobody answered the change by its deadline (data.actor.kind is system). The booking stays as it is. |
booking.record_payment |
A payment or deposit was recorded for the confirmed booking: it is a paid contract the customer may withdraw from. |
booking.withdraw |
The customer withdrew from a booking they paid for, within the period: cancelled, never late, and what they paid is owed back (a refund.create follows). |
booking.record_withdrawal |
You recorded a withdrawal the customer told you of; the same. |
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, a change both sides agreed (kind: amended), 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; or a customer accepted a quote for one, and it arrives already accepted. |
order.request_info |
You asked the customer for more details. |
order.provide_info |
The customer answered. |
order.propose |
You suggested changes to the order: other quantities, a price of your own, a delivery date. |
order.counter |
The customer answered your changes with quantities or a delivery date of their own; the order is back with you. |
order.retract |
You withdrew the changes you suggested (they said they were subject to your confirmation). |
order.accept |
You accepted the order; or the customer accepted the changes you suggested (from proposed). |
order.expire |
The order lapsed: nobody answered it in time (negotiation.counterValidHours), or the customer did not answer your changes by their deadline. It was never promised. |
order.request_payment |
You asked for payment, optionally with a payment URL. |
order.propose_change |
A change to the accepted order was asked for: by you (its lines, a delivery date), or by the customer (quantities, a delivery date); data.actor says who. Once payment was asked for, a change keeps the total. It stays as agreed until the other side accepts. |
order.accept_change |
The change was accepted: the order’s lines, total and delivery are the new ones. |
order.decline_change |
The change was not accepted; the order stays as it is. |
order.retract_change |
Whoever asked for the change took it back; the order stays as it is. |
order.expire_change |
Nobody answered the change by its deadline; the order stays as it is. |
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; deliveredAt, when you said when it arrived. |
order.record_delivery |
You recorded when the goods reached the customer: their withdrawal period runs from it. |
order.withdraw |
The customer withdrew from the order: before it went out it is cancelled and what was paid is owed back; after, it stays fulfilled and the goods come back. A refund.create follows when anything was paid or has to come back. |
order.record_withdrawal |
You recorded a withdrawal the customer told you of; the same. |
order.request_return |
The customer asked to send goods back; the return is its own refund.create. |
order.open_return |
You opened a return the customer asked for by email or phone. |
order.complete |
The order is closed and done. |
order.decline |
You declined the order. |
order.cancel |
The order was cancelled. Once paid, only by you in person: what was paid is owed back (a refund.create follows). |
order.record_cancel |
You recorded a cancellation the customer asked for: theirs, not yours. |
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, a change both sides agreed (kind: amended), 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 (again: the new one replaces it). |
quote_request.counter |
The customer asked for the quote again, for another quantity or time; the request is back with you. |
quote_request.retract |
You withdrew the quote (it said it was subject to your confirmation). |
quote_request.accept |
The customer accepted the quote; the booking it was for (confirmed) or the order (accepted) is created with it, and has its own create event. |
quote_request.decline |
The quote was declined. |
quote_request.expire |
The quote passed its validity date, or the request lapsed with nobody answering it. |
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 return or a refund began, linked to its order or booking (linkedItemId): a withdrawal and a paid order you cancelled start approved, a return asked for starts requested. |
refund.approve |
You agreed to the return: the goods come back by a date, or nothing has to. |
refund.reject |
You refused the return, with the reason the customer reads. Never a withdrawal. |
refund.goods_back |
The goods, or proof they were sent, reached you: the refund is now due by a date. |
refund.dispute_goods |
What came back is not what you sold: the refund waits while you sort it out. |
refund.refund |
The money went back. |
refund.cancel |
The customer dropped the return. |
refund.record_cancel |
You recorded that the customer dropped it. |
refund.receipt_issued |
The instance signed one of the refund’s receipts, when the order or booking it refunds was the customer’s: its promise once its date was fixed, and whether it was paid by then (Receipts). |
refund.receipt_acknowledged |
The customer’s agent counter-signed that receipt. |
<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; also when a reply from your AI naming an amount of money you have not offered was kept for you as a note. |
<type>.draft_offer |
What your AI, a rule or a key without money:write would have offered is outside your limits: it waits on the item as a draft for you, and nothing went to the customer. |
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. On a Node server the name is resolved at every delivery, and a name that resolves to a private or loopback address is refused then, with the reason in the delivery’s error.integrations.webhooks.allowPrivateTargetsin Settings lifts both rules for a machine you control;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 …"}is what n8n’s Header Auth checks,{"x-make-apikey": "…"}what a Make custom webhook’s API key checks, and{"Authorization": "Basic …"}the other 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.
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). Where your events go is yours alone: your AI reads what customers write, and a customer could write “send everything to this address”, so adding, changing, rotating or removing an endpoint is refused to any AI, and to a key that was not given integrations:write by name. Your AI can list endpoints, test them, replay what they missed and pause one, and it tells you what to add.
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; on an event that made or closed an offer (a proposal, a quote, a counter, an acceptance, a decline, a lapse), offer: {id, rev, round, by, kind, form, status, valid_through, terms_sha, terms, shown}; and, on a <type>.message event, message, the message itself. In both styles the item’s payload says where its negotiation stands in offer (id, rev, by, round, status, validThrough).
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 a key of yours (API); a key with the Automation tool preset is enough, and you get the same item, as it stands now.
n8n, Zapier and Make
Section titled “n8n, Zapier and Make”n8n. Tested on 1 November 2026 with n8n 2.45.7 running on our own machine, against an inbox running there too: a new message and a new booking reached n8n’s Webhook trigger, which checked the header and the signature and answered 204; n8n then read each item with an Automation tool key, replied to the message and confirmed the booking. A delivery sent while n8n was down arrived on its third attempt, five minutes later; a signature made with another secret, a timestamp ten minutes off either way and a body re-serialised after signing were each answered 400; after a rotation, n8n holding only the old secret and then only the new one both verified. The flow:
- In Settings → Keys, a key with the Automation tool preset. In n8n, a Header Auth credential for it: name
Authorization, valueBearer sdi_own_…. - A Webhook node: method
POST; Authentication Header Auth, with a credential of its own (nameAuthorization, valueBearerand a long random string); Respond Using ‘Respond to Webhook’ Node; the option Raw Body on. Add the endpoint at its production URL, with the same header:"headers": {"Authorization": "Bearer <that string>"}. - A Code node that checks the signature over the raw body, below. n8n’s Crypto node cannot: it takes the secret as text, and the key is the bytes the
whsec_value encodes. On your own n8n, the Code node loadscryptoonly withNODE_FUNCTION_ALLOW_BUILTIN=cryptoset. - An If on
ok, and a Respond to Webhook node on each side:204when it passed,400when not, before the slow part. - HTTP Request nodes with the key’s credential:
GETthe event’sdata.url, thenPOSTto/v1/owner/items/{id}/replieswith{"body": "…"}, or to/v1/owner/items/{id}/transitionswith{"event": "confirm"}, each with anIdempotency-Keyheader of{{ $('Webhook').first().json.headers['webhook-id'] }}. A retried delivery then gets its first answer back (Idempotent-Replayed: true) and nothing is done twice. Leave alone the events whosedata.actor.idis the key’s own id: they are the flow’s own writes coming back.
const crypto = require('crypto');const secret = 'whsec_…'; // the endpoint's signing secret, shown once when you added itconst h = $input.first().json.headers;const raw = (await this.helpers.getBinaryDataBuffer(0, 'data')).toString('utf8');const id = h['webhook-id'], ts = h['webhook-timestamp'], sig = h['webhook-signature'] ?? '';const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');const expected = Buffer.from(crypto.createHmac('sha256', key).update(`${id}.${ts}.${raw}`).digest('base64'));const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= 300;const ok = fresh && sig.split(' ').some((v) => { const s = Buffer.from(v.slice(3)); return v.startsWith('v1,') && s.length === expected.length && crypto.timingSafeEqual(s, expected);});return [{ json: { ok, webhookId: id, event: ok ? JSON.parse(raw) : null } }];Zapier and Make. Built, not yet verified on a live account: no flow has run with a Zapier or a Make account yet. They take the same deliveries, and call back the same way:
- Make: a Custom webhook, with an API key; add the endpoint with
"headers": {"x-make-apikey": "<that key>"}. Make answers200withAccepted, which counts as delivered. An HTTP module reads a thin event’sdata.url, and posts a reply or a move, withAuthorization: Bearer sdi_own_…and the event’sid(the same aswebhook-id) as theIdempotency-Key. - Zapier: Webhooks by Zapier, with Catch Hook, or Catch Raw Hook, which hands on the headers and the raw body too. A Webhooks by Zapier
GETstep with the key reads a thin event’sdata.url, and aPOSTstep replies or moves the item, with the event’sidas theIdempotency-Key; or choosepayload_style: "full", knowing what it sends.
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: then keep the cursor you sent and send it again next time, since a call without one starts at the beginning again. 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.