Products

Ships to

Developer guideiMessage API8 min read

iMessage webhooks: signing, retries and ordering

How iMessage API webhooks should work with Flow: signature checks, retries, duplicate events, ordering and replay, with code samples.

The short answer

A reliable Flow iMessage webhook is signed, retried, deduplicated and replayable. Verify the signature on the raw body, treat every delivery as at least once, and keep the event ID. Flow retries failed deliveries for three days and keeps a replayable event log.

Building on the iMessage API? Build on iMessage with Flow: one agent for iMessage, WhatsApp and Telegram. New to it? Read the iMessage API guide.

What a webhook has to do

When someone writes to your iMessage line, the provider posts an event to your HTTPS endpoint. That one request has to be trustworthy and survivable. Four things make it so:

  1. Signed. You can prove the request came from the provider.
  2. Retried. A short outage on your side does not lose the message.
  3. Deduplicated. Retries and redeliveries do not run your action twice.
  4. Replayable. You can fetch what you missed.

The iMessage API guide covers the surrounding design. This post is about the delivery itself, using Flow’s events docs.

Verify the signature

Every Flow delivery carries these headers:

Flow-Signature: t=1791763200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Flow-Event-Id: evt_01JB8ZC3K5M7P9R1T3V5X7Z9B1
Flow-Event-Type: message.received
Flow-Version: 2026-11-01

v1 is the lowercase hex HMAC-SHA256, keyed with the endpoint secret, of the string t.{t}.{body}: the letter t, a dot, the timestamp, a dot, and the raw request body exactly as received. Compare in constant time and reject timestamps more than five minutes from your clock.

The SDKs do this for you. In Express:

// The raw body is required: a parsed and re-serialized body will not match.
app.post("/flow", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = await flow.webhooks.constructEvent(req.body, req.header("Flow-Signature"), process.env.FLOW_MESSAGING_WEBHOOK_SECRET!);
  } catch {
    return res.status(400).end();
  }
  // handle the event
  res.json({});
});

In Python, construct_event(body, request.headers.get("Flow-Signature")) does the same and raises WebhookSignatureError. In Go, check it by hand.

You get the endpoint secret, whsec_..., once, when you create the endpoint with POST /v1/webhook_endpoints. Store it as FLOW_MESSAGING_WEBHOOK_SECRET. A test key’s endpoint receives test events, so going live means registering the endpoint again with the live key.

Retries, duplicates and order

  • At least once. Flow’s deliveries are at least once. Deduplicate on the event’s id, which is also in the Flow-Event-Id header.
  • Retried for three days. A failed delivery is retried, so an outage of up to three days loses nothing.
  • In order per conversation. Flow delivers one event at a time, in order, per conversation.
  • Answer fast. Flow waits 10 seconds for the answer. For slow work, return {} at once and send the reply with POST /v1/conversations/{conversation_id}/messages.
  • Idempotent replies. Use the event ID as the Idempotency-Key, and a redelivered event cannot make Flow send the webhook-answer reply twice.

Status events such as message.sent and message.delivered are high volume. Each endpoint subscribes only to the event types it lists, so subscribe to what you use.

Recover after downtime

Webhooks, the live stream and polling all read the same log. After an outage you can catch up without guessing:

curl -G https://api.flow.engineer/v1/events \
  -H "Authorization: Bearer $FLOW_MESSAGING_KEY" \
  --data-urlencode "after=evt_01JB8ZC3K5M7P9R1T3V5X7Z9B1" \
  --data-urlencode "type=message.received"

Events come oldest first, up to 100 a page, and has_more says whether to continue. Events are kept for your plan’s retention, 30 days by default. The live stream resumes from an after ID too, then goes live.

Rotate the secret

POST /v1/webhook_endpoints/{id}/rotate_secret returns a new secret, shown once. The old secret keeps signing for overlap_seconds, one day by default, so during the overlap every delivery carries two v1 values and verifies with either secret. Send an Idempotency-Key with the rotation and retry with the same key. A retry without one is a second rotation.

Put the webhook into your agent

Flow’s signed, ordered and replayable event stream gives your agent a dependable boundary between the channel and your application. For the complete send loop, see Send iMessage programmatically and Build an iMessage AI agent.

Frequently asked questions

How do I verify an iMessage webhook?

Compute the HMAC of the raw request body with your endpoint secret and compare it with the signature header in constant time. Reject old timestamps. Flow signs with HMAC-SHA256 and rejects timestamps more than five minutes from your clock.

Why use the raw body?

The signature covers the exact bytes that were sent. If your framework parses the JSON and you serialise it again, the bytes can differ and the check fails.

Can I receive the same webhook twice?

Yes. Flow's deliveries are at least once, so deduplicate on the event ID and make your handler idempotent.

What happens if my server is down?

Flow retries a delivery for three days and keeps every event in a log you can read with GET /v1/events?after=.

Are events delivered in order?

On Flow, events are delivered in order per conversation, one at a time. Do not assume order on a provider that does not say so.

How do I rotate a webhook secret without downtime?

On Flow, rotate the secret and the old one keeps signing for an overlap period, one day by default. Each delivery then carries two signature values, and your code accepts either while you deploy the new secret.

Start hereiMessage API: a developer’s guideThe overview of how iMessage APIs work, and what to ask a provider. Developer guideSend iMessage programmaticallycurl, TypeScript and Python: get a key, receive a signed webhook, reply once and handle retries.Developer guideiMessage API without a MacWhy a hosted iMessage API removes the Mac, what the DIY relay costs you, and how to choose.Developer guideiMessage MCP serverTwo things people mean by it: a messaging MCP for your coding agent, and your product as an MCP server.Developer guideiMessage API for Node.js and TypeScriptInstall the SDK, stream inbound messages, verify signed webhooks in Express and reply safely.Developer guideiMessage API for PythonStream inbound events, verify signed webhooks in FastAPI and reply with idempotency keys.Developer guideiMessage API for GoPoll events, reply and verify signed webhooks with the standard library, no SDK needed.

All posts: the Flow blog. The Flow docs have the reference.

From reading to building

Your agent, on iMessage and everywhere else.

Flow gives your agent an iMessage line, and the same code answers on WhatsApp and Telegram and works as an MCP server and CLI. See how it works on iMessage.

Build on iMessage with Flow