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 with
the receiver, signature
verification, and endpoint
management. 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. Samples on this page are shown in TypeScript, Python, and Go; the patterns are framework-agnostic and transfer directly to the other supported SDK languages.
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 ranked higher than 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.
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. Scaling & deployment covers the swap.
Next steps
Managing Webhook Endpoints
The management surface for webhook endpoints. Register endpoints, capture the signing secret exactly once, toggle endpoints, and rotate secrets safely.
Securing Your Sent Integration: Secrets, CORS, and PII
Hardening a Sent integration: per-request credentials, HTTPS, security headers and CORS, edge validation, rate limiting, and no secrets or PII in logs.