Tracking Delivery Status
You send a message and get back a QUEUED acknowledgement. Its real outcome —
delivered, read, or failed — arrives later, asynchronously, over the webhooks you built in
the previous three pages. This page closes the loop: how to fold that event stream into a
single, queryable status per message.
The pattern is small and the same in every language: record on send, advance forward-only on each webhook, expose a read endpoint.
For the product-level view of what each status means, see Message status tracking and the event types reference.
The status model
Statuses form a strict progression. A message ranks up as it advances and never goes backwards — this is what makes out-of-order and duplicate deliveries safe.
| Status | Rank | Meaning |
|---|---|---|
QUEUED | 0 | Accepted by Sent, not yet routed. |
ROUTED | 1 | Handed to a carrier / channel. |
SENT | 2 | Left Sent toward the recipient. |
DELIVERED | 3 | Confirmed delivered. |
FAILED | 3 | Terminal failure (shares the rank of DELIVERED). |
READ | 4 | Recipient read it — the only state above DELIVERED. |
Two rules fall out of the ranks:
- Forward-only. Apply a new status only if its rank is higher than the current one. A
late
SENTwebhook arriving afterDELIVEREDis ignored. - Terminal is terminal.
READandFAILEDare locked; nothing supersedes them. ADELIVEREDand aFAILEDcan't overwrite each other — they share a rank, so neither wins a race.
Events can arrive out of order. Sent delivers at least once and over an unordered
network, so you may see DELIVERED before SENT. Ranking — not arrival order — decides the
stored status. This is the general shape of a race condition: two events that could apply
in either order, where "whichever happens to arrive first" would silently give a wrong or
inconsistent result if you let it — the forward-only rank rule above is what closes it off.
Idempotency and dedupe
Because delivery is at-least-once, the same event can arrive twice. Two layers protect you:
- Dedupe on a per-event key at the receiver —
message_id+message_status, a no-op before processing even starts (built in The webhook receiver). Don't key offX-Webhook-ID: that's the endpoint id, identical on every delivery. - Forward-only updates in the store — even if a duplicate slips through, re-applying the same or a lower-ranked status changes nothing. This is your real safety net.
Together they make the whole pipeline safe to replay.
The message store
The store records a message when you send it, then updateStatus advances it as webhooks
arrive. updateStatus is the heart of it: it's a no-op if the id is unknown, if the message
is terminal, or if the incoming rank isn't higher.
const RANK: Record<string, number> = {
QUEUED: 0, ROUTED: 1, SENT: 2, DELIVERED: 3, FAILED: 3, READ: 4,
};
const TERMINAL = new Set(["READ", "FAILED"]);
updateStatus(id: string, next: string): StoredMessage | null {
const record = this.map.get(id);
if (!record) return null; // unknown id
const status = normalize(next);
if (!status) return record;
if (TERMINAL.has(record.status)) return record; // locked
if (rank(status) <= rank(record.status)) return record; // no regress / dupe
record.status = status;
record.updatedAt = new Date().toISOString();
record.history.push({ status, at: record.updatedAt });
return record;
}RANK = {"QUEUED": 0, "ROUTED": 1, "SENT": 2,
"DELIVERED": 3, "FAILED": 3, "READ": 4}
TERMINAL = {"READ", "FAILED"}
def update_status(self, id: str, next_status: str | None) -> dict | None:
with self._lock:
record = self._map.get(id)
if record is None:
return None # unknown id
status = _normalize(next_status)
if not status:
return dict(record)
if record["status"] in TERMINAL:
return dict(record) # locked
if _rank(status) <= _rank(record["status"]):
return dict(record) # no regress / dupe
now = _now()
record["status"] = status
record["updatedAt"] = now
record["history"].append({"status": status, "at": now})
return dict(record)var rank = map[string]int{
"QUEUED": 0, "ROUTED": 1, "SENT": 2,
"DELIVERED": 3, "FAILED": 3, "READ": 4,
}
var terminal = map[string]bool{"READ": true, "FAILED": true}
func (s *MessageStore) UpdateStatus(id, next string) (*StoredMessage, bool) {
s.mu.Lock()
defer s.mu.Unlock()
rec, exists := s.messages[id]
if !exists {
return nil, false // unknown id
}
status, ok := normalize(next)
if !ok {
return rec, true
}
if terminal[rec.Status] {
return rec, true // locked
}
if rank[status] <= rank[rec.Status] {
return rec, true // no regress / dupe
}
now := time.Now().UTC().Format(time.RFC3339)
rec.Status = status
rec.UpdatedAt = now
rec.History = append(rec.History, StatusEvent{Status: status, At: now})
return rec, true
}Mapping events to statuses
The webhook payload usually carries message_status directly. When it doesn't, derive the
status from the event type — the two agree, so either source works:
const STATUS_BY_EVENT: Record<string, string> = {
"message.queued": "QUEUED",
"message.routed": "ROUTED",
"message.sent": "SENT",
"message.delivered": "DELIVERED",
"message.read": "READ",
"message.failed": "FAILED",
};
// In the processor, after verification:
const nextStatus = payload.message_status ?? STATUS_BY_EVENT[eventType];
if (payload.message_id && nextStatus) {
messageStore.updateStatus(payload.message_id, nextStatus);
}STATUS_BY_EVENT = {
"message.queued": "QUEUED",
"message.routed": "ROUTED",
"message.sent": "SENT",
"message.delivered": "DELIVERED",
"message.read": "READ",
"message.failed": "FAILED",
}
# In the processor, after verification:
next_status = payload.get("message_status") or STATUS_BY_EVENT.get(event_type)
if message_id and next_status:
message_store.update_status(message_id, next_status)var statusByEvent = map[string]string{
"message.queued": "QUEUED",
"message.routed": "ROUTED",
"message.sent": "SENT",
"message.delivered": "DELIVERED",
"message.read": "READ",
"message.failed": "FAILED",
}
// In the processor, after verification:
next := payload.MessageStatus
if next == "" {
next = statusByEvent[eventType]
}
if payload.MessageID != "" && next != "" {
store.Default.UpdateStatus(payload.MessageID, next)
}message.received (an inbound reply) has no message_id of yours to advance — it's a new
inbound message, not a status update. Route it to your reply-handling logic instead. See
event types.
Exposing the status
Record the message on the send path so there's something to advance, then serve its current
state at GET /api/messages/:id:
If you're multi-tenant, requiring a valid key isn't enough — a message id alone must never be enough to read someone else's status. Record a tenant identifier (a hash of the API key, or your own customer id resolved from it — never the raw key itself) alongside the message, and check it on every read, not just on write. The examples below do this with a SHA-256 hash of the key; single-tenant apps can skip the tenant check entirely, since there's only one tenant.
import { createHash } from "node:crypto";
const tenantIdFor = (apiKey: string) => createHash("sha256").update(apiKey).digest("hex");
// On send: record so webhooks have a row to advance.
const tenantId = tenantIdFor(apiKey); // the key resolved by servicesForRequest, see Authentication
for (const r of result.recipients) {
messageStore.record({
id: r.id, tenantId, to: r.to, channel: r.channel,
templateId: result.templateId, templateName: result.templateName,
status: result.status,
});
}
// Read endpoint: a valid key is necessary but not sufficient — the message's
// tenantId must match the caller's, or this returns 404 (never 403, so a
// wrong-tenant read looks identical to "doesn't exist").
router.get("/:id", (req, res) => {
const apiKey = apiKeyFromRequest(req);
if (!apiKey) {
throw new ApiError(401, "Unauthorized", "Missing API key — send it as Authorization: Bearer <key>.");
}
const message = messageStore.get(req.params.id);
if (!message || message.tenantId !== tenantIdFor(apiKey)) {
return res.status(404).json({ error: "not found" });
}
res.json(message);
});import hashlib
def tenant_id_for(api_key: str) -> str:
return hashlib.sha256(api_key.encode()).hexdigest()
# On send: record so webhooks have a row to advance.
tenant_id = tenant_id_for(api_key) # the key resolved by get_sent_client, see Authentication
for r in result.recipients:
message_store.record(
id=r.id, tenant_id=tenant_id, to=r.to, channel=r.channel,
template_id=result.template_id, template_name=result.template_name,
status=result.status,
)
# Read endpoint: a valid key is necessary but not sufficient — the message's
# tenant_id must match the caller's, or this returns 404 (never 403, so a
# wrong-tenant read looks identical to "doesn't exist").
@router.get("/api/messages/{message_id}")
async def get_message(
message_id: str,
token: str | None = Depends(get_optional_token),
):
api_key = _resolve_api_key(token) # 401s if missing; see Authentication
message = message_store.get(message_id)
if message is None or message.tenant_id != tenant_id_for(api_key):
raise HTTPException(status_code=404, detail="not found")
return messagefunc tenantIDFor(apiKey string) string {
sum := sha256.Sum256([]byte(apiKey))
return hex.EncodeToString(sum[:])
}
// On send: record so webhooks have a row to advance.
tenantID := tenantIDFor(apiKey) // the key resolved by ClientResolver, see Authentication
for _, r := range result.Recipients {
store.Default.Record(store.RecordInput{
ID: r.ID, TenantID: tenantID, To: r.To, Channel: r.Channel,
TemplateID: result.TemplateID, TemplateName: result.TemplateName,
Status: result.Status,
})
}
// Read endpoint: a valid key is necessary but not sufficient — the message's
// TenantID must match the caller's, or this returns 404 (never 403, so a
// wrong-tenant read looks identical to "doesn't exist").
e.GET("/api/messages/:id", func(c echo.Context) error {
apiKey := resolver.APIKeyFromRequest(c)
if apiKey == "" {
return ErrMissingAPIKey // 401; see Authentication
}
msg, ok := store.Default.Get(c.Param("id"))
if !ok || msg.TenantID != tenantIDFor(apiKey) {
return c.JSON(http.StatusNotFound, echo.Map{"error": "not found"})
}
return c.JSON(http.StatusOK, msg)
})Each record keeps a history array too — a timestamped trail of every status it passed
through — which is invaluable when debugging a delivery.
Productionize the store
Keeping this store in process memory is perfect for a single instance and for learning the pattern, but it's the wrong choice in production: it's lost on restart, and a second instance won't see the first's data. The send path and the webhook receiver may even land on different instances.
The logic — record on send, forward-only advance on webhook, dedupe on the per-event key — is
identical against a real datastore. Swap the in-memory map for Redis (with a TTL) or your primary
database. The updateStatus rank check maps cleanly onto a conditional UPDATE ... WHERE rank < :newRank. We cover the swap in
Scaling & deployment.
Next steps
Managing Webhook Endpoints
The /api/webhooks management surface — register endpoints, capture the born-on-registration signing secret exactly once, enable/disable, and rotate secrets with zero downtime. A thin proxy over the SDK's webhooks resource.
Security
Hardening a Sent integration — per-request credentials, HTTPS, CORS, edge validation, rate limiting, and never logging secrets or PII.