Webhooks
The same events the stream carries, POSTed to your endpoint — signed, retried, and replayable from the console.
The response stream is a socket you hold open. A webhook is HTTP delivery of events you subscribed to on the project's Webhooks page — signed, retried on a fixed schedule when your endpoint does not answer, and redeliverable by hand from the console.
That console Retry is redelivery of a webhook attempt. It is not stream
resume. The stream is at-most-once and has no id: / Last-Event-ID. After a
drop on the socket, reopen SSE and call POST /v1/replay.
Endpoints are created on the project's Webhooks page, per environment. An endpoint subscribes to a list of event types; the types are a public contract — new ones are added, none is ever renamed — and the Webhooks page lists every one of them with a sentence each.
What arrives
POST /your/endpoint HTTP/1.1
content-type: application/json
mapier-event-id: 0192a7c4-…
mapier-timestamp: 1789378800
mapier-signature: v1=6f1a…
{
"id": "0192a7c4-…",
"type": "message.received",
"environment": "live",
"occurredAt": "2026-09-14T09:24:31.000Z",
"deliveryId": "0192a7c5-…",
"attempt": 1,
"data": { "conversationId": "…", "from": "+1…", "text": "got it", … }
}data is the payload for that webhook type. When the type is a command
settlement or an inbound bubble, the object matches the corresponding stream
frame. Do not treat the webhook type list as the SSE catalog — the stream also
emits reaction, poll, conversation and location. Webhook subscription
names stay whatever the Webhooks page lists; this page does not add types.
deliveryId and attempt are there so a receiver can deduplicate, which it
must: webhook delivery is at-least-once, because an HTTP retry cannot offer
anything else. That is not the SSE guarantee. attempt starts at 1.
Verifying the signature
Every delivery is signed with HMAC-SHA256 over the string
"${timestamp}.${body}" — the mapier-timestamp header's value, a dot, and
the exact bytes of the request body — using your endpoint's signing
secret, and the hex digest arrives as mapier-signature: v1=<hex>.
Three rules, each of which exists because the alternative has bitten someone:
- Sign the raw bytes. Parse the JSON after you have verified the body you were sent, never a re-serialised copy of it — key order and whitespace are not stable across serialisers.
- Refuse a stale timestamp. Mapier allows 300 seconds of clock skew either way. The timestamp is inside the signed string, so a captured delivery cannot be replayed later without the secret — as long as you check it.
- Accept the request if ANY presented signature matches. During a secret
rotation the header carries two
v1=entries, comma-separated, for 24 hours: the old secret's digest and the new one's. A receiver that only checks the first entry is a receiver that goes down for the length of a rotation.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhookSignature(input: {
secret: string;
body: string; // the raw request body, as bytes decoded to a string
timestamp: string; // the mapier-timestamp header
signature: string; // the mapier-signature header
toleranceSeconds?: number;
}): boolean {
const tolerance = input.toleranceSeconds ?? 300;
const sentAt = Number(input.timestamp);
if (!Number.isFinite(sentAt) || Math.abs(Date.now() / 1000 - sentAt) > tolerance) return false;
const expected = createHmac("sha256", input.secret)
.update(`${input.timestamp}.${input.body}`)
.digest("hex");
return input.signature
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.startsWith("v1="))
.some((entry) => {
const presented = entry.slice(3);
return (
presented.length === expected.length &&
timingSafeEqual(Buffer.from(presented, "hex"), Buffer.from(expected, "hex"))
);
});
}Answer 2xx once you have stored the event, not once you have processed
it: a slow handler that times out is retried, and a fast 200 followed by
your own queue is the shape that never double-processes.
Retries
An attempt has ten seconds to get a 2xx. Anything else — a 5xx, a 4xx, a
timeout, a connection refused — schedules the next attempt on a fixed ladder:
| Attempt | Delay after the previous one |
|---|---|
| 2 | 10 s |
| 3 | 1 min |
| 4 | 5 min |
| 5 | 30 min |
| 6 | 2 h |
Six attempts in all, about two and a half hours end to end. After the sixth
the delivery settles failed and stays on the endpoint's page in the console,
where Retry makes a new attempt by hand — the replay. A redirect is
treated as a failure rather than followed: a 3xx would forward your
customer's message content somewhere nobody reviewed.
Only the first kilobyte of your response is read, and no response body is ever stored or logged.
Endpoints
- https only for a live endpoint; a test endpoint may be plain http. In both, a loopback or private-network address is refused at creation, because an endpoint that reaches into Mapier's own network is a request forgery with a customer's message in it.
- Secrets are shown once, at creation and at rotation, and never again — only a short preview is shown afterwards, because only a hash is kept. Rotate from the endpoint's page; the old secret keeps signing for 24 hours beside the new one.
- Disable an endpoint to stop deliveries without deleting its history. Deliveries queued while it is disabled are not attempted.
The Events page
Every event, delivered or not, is on the project's Events page with an Inspect button that opens the payload and the deliveries it produced — one row per endpoint, each with its attempt count and status. "Did this reach our webhook" is the question an event is opened to answer, and it is answered there without holding two ids in your head.
Related
Reconnection and delivery guarantees
What the stream does not promise, a correct parser, and bounded replay after a drop.
Idempotency and retries
Idempotency-Key is honoured on every send, in both environments — a retry is never a second message by accident. The three group commands are the one live exception.