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:

reaction.added data
{
  "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.

data
{
  "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:

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

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

On this page