Skip to content
imessageapi

Handling inbound webhooks without losing messages

The webhook is where most messaging integrations quietly break. Signature verification, fast acks, idempotency, and the retry behaviour nobody reads about until it bites.

9 min readUpdated August 21, 2026Getting started

Sending is the easy half. The webhook — where replies, delivery statuses and opt-outs arrive — is where integrations fail, and they fail quietly. A dropped delivery status means your reporting is wrong. A dropped opt-out is a compliance problem.

Rule 1 — Verify the signature

Your webhook URL is public. Without verification, anyone who finds it can inject fake customer replies, fake opt-outs, or fake delivery confirmations into your system. Every serious provider signs its payloads.

verify.ts
import { createHmac, timingSafeEqual } from "node:crypto";
 
export function verify(rawBody: string, header: string | null, secret: string) {
if (!header) return false;
 
const expected = createHmac("sha256", secret).update(rawBody).digest();
const received = Buffer.from(header, "hex");
 
// Length check first: timingSafeEqual throws on a mismatch.
if (expected.length !== received.length) return false;
return timingSafeEqual(expected, received);
}

Verify the raw body, not the parsed object

Signatures are computed over exact bytes. If you parse JSON first and re-serialise, key order and whitespace change and every signature fails. Read the body as text, verify, then parse.

Rule 2 — Acknowledge fast, work later

Providers time out slow webhooks and retry them. If your handler sends an email, writes to three tables and calls an AI model before responding, you will get duplicate deliveries of the same event. Acknowledge in milliseconds and do the work afterwards.

app/api/inbound/route.ts
import { after } from "next/server";
 
export async function POST(request: Request) {
const raw = await request.text();
 
if (!verify(raw, request.headers.get("x-signature"), process.env.WEBHOOK_SECRET!)) {
return new Response("bad signature", { status: 401 });
}
 
const event = JSON.parse(raw);
 
// Opt-outs are legally load-bearing — handle them inline, not after the ack.
if (isOptOut(event)) {
await optOut(event.from);
return Response.json({ ok: true });
}
 
// Everything else can happen after the response goes out.
after(() => processEvent(event));
return Response.json({ ok: true });
}

Rule 3 — Assume every event arrives twice

Retries mean at-least-once delivery, not exactly-once. Every handler must be idempotent. The cheapest way is a table of processed event IDs with a unique constraint.

idempotency.ts
export async function processOnce(eventId: string, work: () => Promise<void>) {
try {
// Unique constraint on eventId does the deduplication for you.
await db.processedEvent.create({ data: { id: eventId } });
} catch {
return { skipped: true }; // already handled
}
 
await work();
return { skipped: false };
}

Rule 4 — Events arrive out of order

A 'delivered' event can land before the 'sent' event it follows. Never derive state by overwriting on each event — compare timestamps, or model status as a set of observed facts rather than a single mutable field.

Rule 5 — Log the ones you do not understand

Providers add event types without telling you. Log unknown types instead of throwing, so an unrecognised event is a line in a log rather than a 500 that triggers a retry storm.

Test with a tunnel before you deploy

Point the provider's webhook at a local tunnel and send yourself real messages. Fifteen minutes of this catches more problems than a day of reading documentation, and it is how you discover what the payloads actually look like.

Next step

Generate a tagged link for whatever you send next with the UTM builder, see what this looks like in your industry, or compare the services that can send it on the providers page.