Outbound webhooks
Outbound webhooks push product events to a URL you control. They carry the same events as the WebSocket and SSE streams, but survive your service being offline: delivery retries, and a signature proves the request came from inbox.
A naming collision worth knowing before you read further. /v1/webhooks is outbound
management — registering the endpoints inbox delivers to. /webhooks/:integrationKey (no /v1)
is inbound ingestion — where providers deliver to inbox. Opposite directions, unrelated
credentials. See Provider webhooks.
Registering an endpoint
curl -X POST https://inbox.example.com/v1/webhooks \
-H "x-api-key: sk_..." \
-H "idempotency-key: $(uuidgen)" \
-H "content-type: application/json" \
-d '{"url":"https://example.com/hooks/axis","events":[]}'
{
"id": "...",
"appId": "...",
"url": "https://example.com/hooks/axis",
"events": [],
"enabled": true,
"createdAt": "2026-08-31T...",
"secret": "a3f1…"
}
Scope is admin.
The plaintext secret is returned once and never again. It is stored encrypted (secretEnc) and
GET /v1/webhooks strips it from every row. If you lose it, delete the endpoint and register a new
one — there is no rotate and no reveal.
Endpoints are keyed on auth.appId, not the tenant. One registration receives events for every
tenant of that app, and DELETE /v1/webhooks/:id refuses an endpoint belonging to another app with a
not_found. Your handler must therefore not assume a single workspace.
An empty events array means all product events — not literally every event on the bus. See the
allowlist below.
| Method | Path | Notes |
|---|---|---|
| POST | /v1/webhooks |
201; returns the plaintext secret once |
| GET | /v1/webhooks |
Secrets stripped |
| DELETE | /v1/webhooks/:id |
204 |
What is actually delivered
Only these six event types are ever queued:
const DELIVERABLE_EVENT_TYPES = new Set<string>([
'message.received',
'comment.received',
'thread.reply',
'post.status',
'account.updated',
'review.new',
]);
Inbox's internal @Emits business and audit events — webhook_endpoint.registered,
connection.connected, campaign.report_viewed, and dozens more — travel on the same bus but are
explicitly gated off. Two reasons, and the second is the fun one:
- They are Notify and observability signals. Forwarding them leaks internal operations to customers.
- Registering an endpoint would immediately deliver its own registration event to itself. The e2e suite caught exactly that.
So events: [] means "all product events". If you subscribe to a name outside this set, nothing will
ever arrive.
Signing
Every delivery is signed with your endpoint's secret:
signature = 'sha256=' + HMAC_SHA256(secret, `${timestamp}.${rawBody}`) // hex
Headers on the POST to your URL:
| Header | Value |
|---|---|
content-type |
application/json |
x-axis-event |
The event type, e.g. message.received |
x-axis-timestamp |
Unix seconds, and part of the signed string |
x-axis-signature |
sha256=<hex> |
x-axis-delivery |
A UUID unique to this delivery attempt |
x-axis-delivery changes on every retry, so it identifies an attempt. Deduplicate on the event
content, not on this header.
Verifying
import express from 'express';
import { verifyWebhookSignature } from '@~inbox/sdk';
const app = express();
// RAW body — this is the part that matters.
app.post(
'/hooks/axis',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body.toString('utf8');
const ok = verifyWebhookSignature({
secret: process.env.AXIS_WEBHOOK_SECRET,
rawBody,
timestamp: req.get('x-axis-timestamp'),
signature: req.get('x-axis-signature'),
});
if (!ok) return res.sendStatus(401);
const event = JSON.parse(rawBody);
switch (event.type) {
case 'message.received': /* … */ break;
case 'thread.reply': /* … */ break;
default: break;
}
res.sendStatus(200); // any 2xx
},
);
You must verify against the raw body, not a re-serialized object. express.json() parses the
body and throws the original bytes away; JSON.stringify(req.body) then produces a different
string — different key order, different whitespace, different unicode escaping — and the HMAC will
never match. This is the single most common integration failure with signed webhooks, and it presents
as "signature verification always fails" with no clue as to why. Register a raw body parser on the
webhook route specifically.
verifyWebhookSignature compares in constant time, returns false rather than throwing on a length
mismatch, and tolerates a missing sha256= prefix on the provided signature.
Verifying by hand is four lines if you are not on Node:
expected = "sha256=" + hex(hmac_sha256(secret, timestamp + "." + raw_body))
constant_time_equal(expected, x_axis_signature)
Retries and dead-lettering
Any non-2xx response — or a connection error, or a timeout — is a failure. The delivery is re-enqueued with a backoff of 60s, 5m, 15m, 30m, then 60m for every attempt after that. After 8 attempts it is dead-lettered: logged at error level and dropped.
The backoff is applied as a queue delay, so a worker is never blocked waiting an hour.
Answer 2xx as soon as you have durably accepted the payload; do your processing afterwards. A handler that does real work inline turns a slow database into a retry storm.
A deleted or disabled endpoint stops receiving immediately — an in-flight delivery for it returns without sending.