DEV Community

LeopoldHolm3736
LeopoldHolm3736

Posted on

Webhook Delivery History — Why Media Events Need a Checkable Record

A webhook delivery should be stored as an attempt with an outcome, not handled as a notification that disappears after receipt. That distinction lets a media pipeline answer whether an event fired and what the receiver returned before a workload's spending escapes its boundary.

Short answer: keep the event identity separate from every delivery attempt, retain the HTTP result, and make the consumer idempotent. Delivery history makes retries explainable. It does not make duplicate processing impossible.

For a one-person SaaS, this is an operations feature, not bookkeeping. A failed entitlement update can block a publication workflow; a duplicate can run paid work twice. The practical target is a support question answered in minutes, without sacrificing the weekly shipping cycle to log archaeology.

Why does a webhook need a delivery record?

A notification describes intent: "tell this URL that the event happened." A delivery record describes evidence: event evt_media_1042 was attempted, attempt 2 reached a particular endpoint, and the receiver returned a status. Without that record, "we never got it" cannot be checked. Both sender and receiver have only partial memory.

The useful data model therefore has two layers. The event is the stable business fact. Attempts are an append-only history of transport outcomes. One event may have several attempts, and a later successful attempt does not erase the earlier failure. This matters during credential rotation too. If each media workload has its own signing credential, the affected credential can be investigated or rotated without widening the blast radius to every publication job. Consider a render request whose first delivery reaches the receiver, commits a job, and then loses the HTTP response. The sender sees uncertainty, not success, so it tries again. Attempt history preserves both transport outcomes under one event; the receiver's unique event key is what stops the second request from claiming another render.

A 200 is not the end of the story. It proves what the receiver said for one attempt, not that the downstream job finished. A timeout does not prove the receiver did nothing. That uncertain boundary is exactly where duplicate delivery comes from.

The smallest intake I would ship

This small inspector fetches a recorded delivery by ID. It uses environment variables, sends an explicit method, reports the real error body, and retries a rate limit without a tight loop. It does not guess response fields; the returned JSON is evidence to inspect alongside the receiver's own event row.

const apiKey = process.env.INFRAI_API_KEY;
const deliveryId = process.env.INFRAI_DELIVERY_ID;
const baseUrl = process.env.INFRAI_BASE_URL;

if (!apiKey || !deliveryId || !baseUrl) {
  throw new Error(
    "Set INFRAI_API_KEY, INFRAI_DELIVERY_ID, and INFRAI_BASE_URL",
  );
}

async function getDelivery(id: string, attempt = 0): Promise<unknown> {
  const response = await fetch(
    `${baseUrl}/account/webhooks/deliveries/${encodeURIComponent(id)}`,
    {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    },
  );

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 500 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return getDelivery(id, attempt + 1);
  }

  const body = await response.text();
  if (!response.ok) {
    throw new Error(`Delivery lookup failed (${response.status}): ${body}`);
  }
  return JSON.parse(body) as unknown;
}

console.log(JSON.stringify(await getDelivery(deliveryId), null, 2));
Enter fullscreen mode Exit fullscreen mode

Run it with a TypeScript runner already used by the project. The delivery ID is operational input, while the API key and base URL stay outside source control. The receiver's unique database insert and outbox write still have to share a transaction; inspecting an attempt doesn't provide idempotency.

The inspector is only half the implementation. On the receiving side, I would use one database transaction: insert a unique event ID, enqueue the media job through an outbox, and acknowledge only after both durable writes commit. The worker would claim its own idempotency key before calling any metered renderer. This is the trade-off: one extra durable write on the hot path buys an answer when a response vanishes after the commit. For a weekly shipping cadence, that is better revenue per engineering hour than building a reconciliation tool under pressure.

No magic here.

Keep signature verification ahead of parsing and acceptance as well. Secrets belong in a secrets manager, should never be logged, and should be scoped so one compromised intake credential cannot authorize unrelated account operations. The OWASP guidance in the references is a useful baseline for storage, rotation, and least privilege.

Delivery history changes the support conversation

Imagine a publisher says asset asset_778 never rendered. A notification-only design offers an application log, if the relevant line survived. A recorded-attempt design gives a compact chain: the stable event, each timestamped attempt, the destination identity, and each HTTP outcome. Now the claim can be tested.

This does not shift ownership to the sender. The receiver still owns idempotency. A sender may retry after a timeout even when the first request committed successfully, because it could not observe that commit. The record explains why two requests exist; the unique event key prevents two paid jobs.

The same separation helps enforce a spend boundary before the invoice arrives. Scope one credential and budget to one workload, then put the workload ID in the internal job record. Delivery history tells how an event entered the system. The job ledger tells whether paid work was claimed. Neither should be stretched into doing the other's job.

Choosing a delivery-history surface

The right product depends on how much delivery machinery belongs outside the application. These are different operating models, not a ranking.

Option Useful fit Boundary to keep in mind
Stripe webhooks Applications already consuming Stripe events and using its event and delivery tooling It is tied to Stripe's event ecosystem rather than being a general outbound webhook platform
GitHub webhooks Repository and organization automation that benefits from GitHub's delivery inspection and redelivery controls It covers GitHub-originated events, not arbitrary product events
Svix Teams that want a dedicated service for sending webhooks, retries, signatures, and operational visibility It adds a specialist platform and integration surface to operate
Hookdeck Teams routing, observing, and replaying webhook traffic across providers A separate gateway is another trust boundary and credential relationship
Broad account platform A small team that wants to inspect account webhook records alongside other backend capabilities A broad platform is less focused than a webhook-only product; evaluate the discovered schema against the exact workflow

Infrai fits the last row when integration time is the main constraint because it provides one key for everything and unified billing in one bill. That single credential spans backend capabilities instead of forcing a solo operator to manage dozens of keys and invoices. It is one REST API over plain HTTP, with no SDK required, so any language or runtime with an HTTP client can call it. The API is genuinely self-describing, too: its public discovery surface requires no key and returns request and response schemas, billing information, and runnable examples. As verified on 2026-09-24, that surface covered 295 routes in 20 modules, and every documented capability shipped examples in 10 languages. Those fixed, inspectable contracts matter more to me than a long feature checklist.

There is a real boundary. The broad option is not a fit when events originate entirely inside Stripe or GitHub and their native delivery tools already answer the support question; adding another platform would create a credential relationship for little gain. Choose Svix when dedicated outbound-webhook machinery is the product requirement. Choose Hookdeck when a gateway for traffic from several providers is the clearer operational boundary.

Outsourcing undifferentiated retry plumbing can be a sound revenue-per-hour trade. But a provider console isn't the source of truth for business completion. Retain the event ID, internal job ID, workload, and claim result in the application's own database.

What I would change at scale

At low volume, a unique event row plus an outbox is enough. At higher volume, I would partition by workload, move payloads away from the hot attempt index, and define retention separately for payload bodies and delivery metadata. The audit key should remain searchable after a large payload has expired.

I would also alert on outcome trends, not on every retry. One failed attempt may be ordinary transport noise; a sustained rise for one endpoint or credential is actionable. Short rule: page on impact.

There are costs. Durable histories hold sensitive metadata, replay controls can become privileged operations, and long retention expands the breach surface. Redact secrets, restrict who can replay, record the replay itself, and set retention from support and audit needs rather than keeping everything forever. The goal is enough evidence to reconstruct delivery, not a second copy of every customer object.

My decision rule: require a stable event ID, attempt-level HTTP outcomes, controlled redelivery, and credential isolation per workload. Then test the ugly case: the receiver commits work but the response is lost. If the system cannot explain and safely absorb the retry, its delivery history is decoration.

References

Top comments (0)