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
| Event | What it means | What to do with it |
|---|---|---|
| Message sent / queued | The provider accepted it. Nothing has been delivered yet. | Store the handle. Do not tell the user it arrived. |
| Delivered | Apple confirmed delivery to the device. | This is the one worth counting. |
| Read | The recipient opened it — only if they have read receipts on. | Useful signal, never a reliable metric. Why. |
| Failed / undelivered | Wrong number, not on iMessage, or the line is in trouble. | Alert if the rate moves. This is your early warning. |
| Inbound message | A human replied. | Route it to a person fast. Someone has to answer. |
| Reaction / tapback | A thumbs-up, heart or emphasis on your message. | Often a confirmation. Count it as one where it makes sense. |
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.
- Verify the signature.
- Return 200 immediately.
- Hand the payload to a queue, a background task, or
waitUntil. - 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.
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-arrivingdeliveredmust 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.
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.