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.

StatusRankMeaning
QUEUED0Accepted by Sent, not yet routed.
ROUTED1Handed to a carrier / channel.
SENT2Left Sent toward the recipient.
DELIVERED3Confirmed delivered.
FAILED3Terminal failure (shares the rank of DELIVERED).
READ4Recipient 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 SENT webhook arriving after DELIVERED is ignored.
  • Terminal is terminal. READ and FAILED are locked; nothing supersedes them. A DELIVERED and a FAILED can'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:

  1. 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 off X-Webhook-ID: that's the endpoint id, identical on every delivery.
  2. 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 message
func 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

On this page