A delivery receipt is the only honest answer to whether a message arrived. Everything upstream of it, the queue acknowledgement and the submission to the carrier, tells you the message made progress rather than that a handset got it. This covers what the states mean, how receipts reach you, and the two consumer bugs behind most of the tickets we see.
The state machine
Five states, one direction, no loops. A message never goes backwards, and terminal means terminal.
- queued - accepted by the gateway, route chosen, waiting for a submission window.
- submitted - handed to the carrier, which acknowledged receipt but not delivery.
- delivered - the network confirmed handset delivery. Terminal.
- failed - rejected or given up on. Terminal, and always carries a reason code.
- expired - the validity period elapsed before the handset became reachable. Terminal.
Not every network returns a real delivery confirmation. Where a carrier only acknowledges submission the message stays at submitted and the route metadata says so, rather than us inventing a delivered state you cannot trust. If a market shows a suspiciously low delivery rate, check that first.
Verify the signature before you trust it
Every webhook body is signed with HMAC-SHA256 using the secret issued when the subscription was created. Compute the digest over the raw body rather than the parsed object, compare in constant time, and reject anything with a timestamp older than five minutes so a captured payload cannot be replayed at you.
verifying an event
import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const [tsPart, sigPart] = header.split(",");
const ts = tsPart.split("=")[1];
const sig = sigPart.split("=")[1];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(ts + "." + rawBody)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}Acknowledge fast, work later
The most common cause of a retry storm is a consumer that does its real work before responding. Write the event to a queue or a table, return a 2xx, process out of band. Anything slower than two seconds is treated as a failure and re-sent.
retry schedule
attempt 1 immediate
attempt 2 +30s
attempt 3 +2m
attempt 4 +10m
attempt 5 +1h
attempt 6 +6h
attempt 7 +24h -> dead letterAfter the final attempt the event moves to a dead-letter queue you can inspect and replay from the console. Nothing is silently dropped.
Duplicates and gaps
Delivery is at-least-once. A network hiccup between your 2xx and our recording of it means you will occasionally see the same event twice, and a consumer that is not idempotent will double-count or double-credit. Key your writes on event.id and the problem disappears entirely.
idempotent consumption
INSERT INTO message_events (event_id, message_id, state, at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (event_id) DO NOTHING;For gaps, every event carries a monotonic sequence per subscription. If your last stored sequence is 8814 and the next arrival is 8817, two events are missing and you can pull them rather than waiting to notice a discrepancy in a monthly report.
Replay what you missed
The event log is queryable for thirty days on every plan. A deploy that broke your consumer over a weekend is recoverable without reconciling anything by hand.
GET /v1/events
curl "https://api.messagereach.net/v1/events?\
after_sequence=8814&class=message.delivered&limit=500" \
-H "Authorization: Bearer $MR_KEY"Reading failure reasons
A reason code tells you which side has to change something, and the two categories deserve opposite handling. Carrier-side reasons are worth a retry later. Content and identity reasons are not: retrying an unregistered sender a hundred times only burns the route's standing with the network.
- absent_subscriber - handset off or out of coverage. Safe to retry inside the validity window.
- sender_rejected - the identity is not registered on that network. Fix the registration, do not retry.
- content_blocked - the network filtered the body, often a shared-domain link. Change the template.
- invalid_msisdn - the number is not routable. Mark it in your own records so it stops being tried.
If content_blocked starts appearing on a campaign that was fine yesterday, the usual culprit is a link. Branded short domains exist precisely because carrier filters treat shared shorteners as phishing.


