Skip to content

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.

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 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.

One POST to the owner API:

Terminal window
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 — https only, and a public host. An IP address, localhost or 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.allowPrivateTargets in 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. PATCH merges: a name with a value sets it, a name with null removes 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.

A POST, content-type: application/json, three signature headers, and nothing else you need to care about:

POST /hooks/inbox HTTP/1.1
host: shop.example.com
content-type: application/json
accept: application/json
user-agent: surfingdog-inbox/0.0.0
webhook-id: 01K5RJ3B4C5D6E7F8G9H0JKMNP
webhook-timestamp: 1789992000
webhook-signature: v1,ceSnlptw5xQUh4NhglImizWi+wQ7rjsL2Dyjl4kXX9U=
sdi-event-type: booking.create
sdi-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.

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:

  1. 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;
  2. webhook-signature carries one or more space-separated v1,<base64> values; any one matching is a pass, which is what lets a secret be rolled without dropping a delivery;
  3. webhook-timestamp is 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;
  4. 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:

Terminal window
npm install @surfingdog/sdk

Then:

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.

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.

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.)

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}/replay with {"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": true it also carries a next_after: call it again with the same since and {"after":"<that value>"} to take the next window, and keep going until truncated is false. Re-sending the same request without after reads 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.

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. 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:

  1. In Settings → Keys, a key with the Automation tool preset. In n8n, a Header Auth credential for it: name Authorization, value Bearer sdi_own_….
  2. A Webhook node: method POST; Authentication Header Auth, with a credential of its own (name Authorization, value Bearer and 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>"}.
  3. 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 loads crypto only with NODE_FUNCTION_ALLOW_BUILTIN=crypto set.
  4. An If on ok, and a Respond to Webhook node on each side: 204 when it passed, 400 when not, before the slow part.
  5. HTTP Request nodes with the key’s credential: GET the event’s data.url, then POST to /v1/owner/items/{id}/replies with {"body": "…"}, or to /v1/owner/items/{id}/transitions with {"event": "confirm"}, each with an Idempotency-Key header 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 whose data.actor.id is 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 it
const 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 answers 200 with Accepted, which counts as delivered. An HTTP module reads a thin event’s data.url, and posts a reply or a move, with Authorization: Bearer sdi_own_… and the event’s id (the same as webhook-id) as the Idempotency-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 GET step with the key reads a thin event’s data.url, and a POST step replies or moves the item, with the event’s id as the Idempotency-Key; or choose payload_style: "full", knowing what it sends.

Poll instead. GET /v1/owner/events is the same stream, read forward from wherever you left off:

Terminal window
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.