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.
The console's Retry now is redelivery of a webhook attempt. It is not
stream resume: the stream resumes itself, from the Last-Event-ID you reconnect
with — see Resuming.
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, and every one carries
projectId and environment among its own fields. A command.settled
webhook's data carries the command frame's fields; read the ones you need
and ignore the rest. message.received names the same message a message frame does,
under its own field names — providerMessageId and replyToMessageId where
the frame has externalId and replyToExternalId — with the frame's
attachments (id, mimeType, filename, byteSize, and isSticker when
the channel said), lineId (the line it arrived on, as
GET /v1/whoami lists it), and isGroup, isVoiceMemo,
effect and service (the
service an iMessage line received the message over: iMessage, SMS or
RCS), each absent when not said — so never read an absent service as
iMessage. message.sent carries isGroup by the same rule: a send into a group
chat has no single recipient, so its to is an empty string and isGroup is
true beside it.
The stream frames only some of those types: message.received as message,
command.settled as command, and — on a stream opened with the option —
message.delivered and message.read as receipt and
message.delivery_failed as delivery_failed, the three location.* types as
location, message.edited and message.unsent as moderation, and the
four poll.* types as poll. Everything else, from a
message's other lifecycle events to line changes, reaches webhooks and the
console and stops there — a new conversation's conversation.created among
them. reaction.added and reaction.removed carry a
contact's tapbacks, outside group chats. actor is the contact's address,
spelled as from is on a message frame, and emoji is the reaction itself:
{
"messageId": "0192a7c4-6a02-7e11-8c4d-2b9f7e3a5d22",
"conversationId": "a0000000-0000-4000-8000-000000000001",
"actor": "+14155550100",
"emoji": "❤️",
"providerReactionId": null,
"occurredAt": "2026-10-04T21:12:09.000Z",
"projectId": "b0000000-0000-4000-8000-000000000002",
"environment": "live"
}Nine types are opt-in: location.share_started, location.updated,
location.share_ended, message.edited, message.unsent, poll.created,
poll.options_changed, poll.voted and poll.vote_cleared. A new
endpoint's default subscription, and Select all, leave them out — a
person's position and their rewritten words reach an endpoint only when
someone ticked those types by name. See
Location events and
Edits and unsends.
Deduplicating
A receiver must deduplicate: webhook delivery is at-least-once, because an
HTTP retry cannot offer anything else. Deduplicate on the event's id — the
mapier-event-id header carries the same value — which every attempt at one
event shares, a retry from the console included. deliveryId names one
scheduled attempt: every retry, automatic or from the console, gets a new one,
so it cannot be your dedupe key. attempt starts at 1.
message.delivery_failed
The provider accepted a message and later reported it not delivered — Apple's
"Not Delivered" on iMessage, and in test the sandbox's Sends, then not
delivered delivery mode. It is the same fact as the stream's
delivery_failed frame.
{
"messageId": "0192a7c4-5a31-7bd2-8e6f-1d0c9b8a7f65",
"conversationId": "a0000000-0000-4000-8000-000000000001",
"failedAt": "2026-08-24T18:05:02.000Z",
"simulated": false,
"commandId": "3f1a0c2e-8b47-4d19-9a5e-2c6f0b8d4e71",
"projectId": "0192a7b0-1c2d-7e3f-8a4b-5c6d7e8f9a0b",
"environment": "live"
}It is not message.failed, and a handler must not treat it as one. The
send did not fail, its delivery did: the message keeps its status, and the
payload carries no errorCode and no failureClass. It can reach you before
the send's message.sent, or with no message.sent at all, so correlate by
commandId rather than assuming you recorded the send first. Nothing retries
the message; a resend is a new message under a new Idempotency-Key. See
Receipts and delivery verdicts.
A message with no text and no attachment
On a live line a chat background set or removed in a conversation with one
person also arrives as a message from that person with no text: a
message.received whose text is null. It arrives whoever made the change —
your own chat_background.set or
chat_background.remove, or the person on their
own phone — and it is stored in the conversation and counted as a received
message.
Nobody wrote anything, so there is nothing to answer. Skip a
message.received that has no text and no attachment rather than reply to
it. Store it and answer 2xx as for any event; what to leave out is the
reply. In a group chat the line's own change files nothing, and a test line
files no such message.
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>. The key is
the whole secret the console showed you, whsec_ prefix included; nothing is
stripped or decoded from it.
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 new secret's digest first, then the old 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; // the endpoint's signing secret, whole — `whsec_` included
body: string | Uint8Array; // the raw request body, exactly as received
timestamp: string | null | undefined; // the mapier-timestamp header
signature: string | null | undefined; // the mapier-signature header
toleranceSeconds?: number;
}): boolean {
// Every refusal is `false`, never a throw. Everything below except the secret
// comes from whoever sent the request, and a throw is a 500 — to a request
// that was simply not authentic, and which is then retried.
const { secret, body, timestamp, signature } = input;
if (!secret || typeof timestamp !== "string" || typeof signature !== "string") return false;
if (!/^\d{1,15}$/.test(timestamp)) return false;
const tolerance = input.toleranceSeconds ?? 300;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > tolerance) return false;
const expected = Buffer.from(
createHmac("sha256", secret).update(`${timestamp}.`).update(body).digest("hex"),
"utf8"
);
return signature
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.startsWith("v1="))
.some((entry) => {
// Compare the hex TEXT as bytes, lower-cased, and never decode it:
// `Buffer.from(value, "hex")` stops silently at the first character that
// is not hex. And compare lengths in BYTES, after encoding, because
// `timingSafeEqual` throws on buffers of different lengths — and 64
// non-ASCII characters are 64 characters but 128 bytes.
const presented = Buffer.from(entry.slice(3).toLowerCase(), "utf8");
return presented.length === expected.length && timingSafeEqual(presented, expected);
});
}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. A failed attempt
with attempts left is failed; after the sixth the delivery settles
exhausted — Gave up on the endpoint's page in the console — and nothing
more is sent unless you press Retry now there, which queues one more
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. It is kept with the attempt and shown on that delivery's row in the console, so the error page your endpoint answered with is readable there; none of it is 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 the first ten characters are shown afterwards. The secret itself is stored, because signing needs it, but the console has no way to read it back. 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 promises, a parser that keeps each frame's id, and resuming with Last-Event-ID after a drop.
Idempotency and retries
Send the same Idempotency-Key again and the first command is adopted rather than repeated — on every command in test, and on every live command but group.create, typing.start and message.read. Leave the header off and every attempt is a new command.