Skip to content
iMessage APIs
Operations9 min read

Handling replies: webhooks, signatures and idempotency

Sending is a solved problem in twelve lines. Everything that goes wrong in month two is on the inbound side.

An iMessage integration has two halves, and they are not equally hard. Outbound is a POST. Inbound is a public endpoint that receives untrusted traffic, must be fast, must be idempotent, and will be handed events in an order you did not expect.

What actually arrives

EventWhat it meansWhat to do with it
Message sent / queuedThe provider accepted it. Nothing has been delivered yet.Store the handle. Do not tell the user it arrived.
DeliveredApple confirmed delivery to the device.This is the one worth counting.
ReadThe recipient opened it — only if they have read receipts on.Useful signal, never a reliable metric. Why.
Failed / undeliveredWrong number, not on iMessage, or the line is in trouble.Alert if the rate moves. This is your early warning.
Inbound messageA human replied.Route it to a person fast. Someone has to answer.
Reaction / tapbackA thumbs-up, heart or emphasis on your message.Often a confirmation. Count it as one where it makes sense.
Names vary by provider; the categories do not.

Verify before you parse

Your webhook URL is guessable and public. Without verification, anyone who finds it can inject fake replies into your CRM, mark messages as delivered that never were, or trigger whatever automation you attached to an inbound event.

The mistake that makes verification silently useless

Computing the HMAC over a re-serialized body. Your framework parsed the JSON, you called JSON.stringify on the object, and now the key order and whitespace differ from what the provider signed. Every request fails, you assume the secret is wrong, and the usual fix is to stop checking. Read the raw bytes, before any body parser touches them.

Use a constant-time comparison — crypto.timingSafeEqual in Node, hmac.compare_digest in Python. A plain === leaks the signature one byte at a time to anyone patient enough to measure.

If your provider signs with a timestamp as well, check that it is recent. A signature alone lets an attacker replay a captured request forever; a five-minute window closes that.

Respond, then work

Providers treat a slow response as a failure and retry. If your handler writes to a database, calls your CRM and posts to Slack before returning 200, then a slow CRM turns one reply into five duplicate replies — and your retry storm arrives exactly when the third-party you depend on is already struggling.

  1. Verify the signature.
  2. Return 200 immediately.
  3. Hand the payload to a queue, a background task, or waitUntil.
  4. Do the real work there, with its own retries.

At-least-once means exactly what it says

You will receive the same event twice. Not occasionally — routinely, whenever a response was slow, a deploy landed mid-request, or the provider's own retry logic fired. Every consequence of an event must therefore be safe to apply twice.

sql
create table imessage_events (
  event_id     text primary key,
  received_at  timestamptz not null default now(),
  payload      jsonb not null
);

-- Insert first. If it conflicts, this event has already been handled
-- and the handler returns without doing anything else.
insert into imessage_events (event_id, payload)
values ($1, $2)
on conflict (event_id) do nothing
returning event_id;

The cheapest idempotency that works: let the database refuse the duplicate.

If your provider does not send a stable event ID, derive one — message handle plus event type plus timestamp — and treat it the same way.

Order is not guaranteed

A delivered event can arrive before the sent event that logically precedes it. A reply can arrive before you have finished writing the outbound record it replies to.

  • Never `UPDATE` a row you assume exists. Upsert on the message handle so an out-of-order event creates the row it needs.
  • Order by the provider's timestamp, not your received_at.
  • Make status transitions monotonic. Once a message is read, a late-arriving delivered must not move it backwards.

Local development

Webhooks cannot reach localhost. Use a tunnel — ngrok, cloudflared, or your framework's own — and point the provider's sandbox at it. Capture one real payload of every event type into a fixtures directory the first time you see it; from then on you can replay them at your handler in CI without touching the network.

bash
SECRET="$IMESSAGE_WEBHOOK_SECRET"
BODY=$(cat fixtures/inbound-message.json)
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')

curl -sS -X POST http://localhost:3000/webhooks/imessage \
  -H "Content-Type: application/json" \
  -H "x-signature: $SIG" \
  --data-raw "$BODY"

Replaying a fixture with a valid signature. Keep this as a script — you will use it every time you touch the handler.

What to monitor

  • Delivered rate over a rolling hour. A drop is the first sign of a line in trouble, and you will see it here before the provider tells you.
  • Failure reasons, grouped. "Not on iMessage" is normal background noise. A new reason appearing is not.
  • Webhook 401 rate. A spike means either a rotated secret or someone probing your endpoint.
  • Time to first human reply. The channel's entire advantage is that it feels like a conversation, and a two-hour reply is not one.

Have an opt-out path that cannot be missed

Inbound text containing STOP, UNSUBSCRIBE or anything close should suppress that recipient before a human ever sees the message, and the suppression must be checked at send time rather than at list-build time. The compliance page covers the obligation; this is the implementation of it.

developerswebhookscode