A delivery receipt is the only honest answer to whether a message arrived. Everything upstream of it - the queue acknowledgement, the submission to the carrier - tells you the message made progress, not that a handset got it. This guide covers what the states mean, how the receipts reach you, and the two consumer bugs that account for most of the tickets we see.
The state machine
Five states, one direction, no loops. A message never goes backwards, and once it is in a terminal state it stays there.
- queued - accepted by the gateway, route selected, waiting for a submission window.
- submitted - handed to the carrier, which has acknowledged receipt but not delivery.
- delivered - the network confirmed handset delivery. Terminal.
- failed - the network rejected it or gave up. 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 in submitted and the route metadata says so, rather than us inventing a delivered state you cannot trust.
Verify the signature first
Every webhook body is signed with HMAC-SHA256 using the secret you received when the subscription was created. Compute the digest over the raw body - not the parsed object - and compare in constant time. 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, and process it 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 last 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-refund. Key your writes on event.id and the problem disappears.
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 4181 and the next arrival is 4184, you know two events are missing and can pull them rather than waiting to notice a discrepancy in a report.
Replay what you missed
The event log is queryable for thirty days on every plan. Deploy broke your consumer over a weekend? Replay the window rather than reconciling by hand.
GET /v2/events
curl "https://api.textreach.online/v2/events?\
after_sequence=4181&class=message.delivered&limit=500" \
-H "Authorization: Bearer $TEXTREACH_KEY"Reading failure reasons
A failure reason tells you which side has to change something, and the two categories deserve different handling. Carrier-side reasons - unreachable handset, temporary network rejection - are worth a retry later. Content and identity reasons are not: retrying an unregistered sender a hundred times just burns the route's reputation.
- 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. Change the template, do not retry.
- invalid_msisdn - the number is not routable. Mark it in your own records so it stops being tried.