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:

  1. 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.
  2. 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.
  3. 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.
verify.ts
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:

AttemptDelay after the previous one
210 s
31 min
45 min
530 min
62 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.

On this page