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.

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 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 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. 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:

  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. We cover the swap in Scaling & deployment.

Next steps

On this page