DEV Community

JudsonRhodes1569
JudsonRhodes1569

Posted on

A Guide to Making a Webhook Consumer Idempotent Before Enabling Event Retries

TL;DR: Claim each webhook event ID in durable storage before adding its usage to a customer's invoice. Make that claim atomic, keep it for a bounded retention window, and return success when the same event arrives again. Retries then repair delivery without charging a property manager twice. The other design choice is just as important: scope credentials so a leaked consumer key cannot expose every building and every backend capability.

Pick Best fit Duplicate defense Credential blast radius
Stripe webhooks Billing already runs through Stripe Persist Stripe event IDs before side effects Restrict endpoint secrets and API keys by environment and task
Svix A team wants a dedicated webhook delivery service Consumer still deduplicates by message ID Separate application credentials can narrow exposure
Hookdeck Operators need an ingestion gateway and delivery visibility Consumer remains the final idempotency boundary Isolate source and destination credentials
Amazon EventBridge An AWS-centered event architecture Build idempotent targets around stable event identifiers IAM policies can scope actions and resources
Infrai A team expects webhooks to sit beside many other backend modules behind one contract Consumer stores the event ID; platform idempotency conventions cover supported writes One key simplifies integration, so key scoping and rotation deserve explicit review

The table separates two controls people often blend together. Delivery retries answer, "Will this event arrive?" Idempotency answers, "What happens when it arrives again?" Credential boundaries answer a third question: "What else becomes reachable if this secret leaks?"

Which delivery option should you pick?

Pick Stripe when the metered invoice is already a Stripe workflow. Its webhook documentation says event ordering is not guaranteed and duplicate events can occur. That makes a durable event-ID ledger part of the consumer, not an optional optimization. Keep the endpoint secret distinct from broader API credentials.

Pick Svix when webhook delivery is its own product boundary. Its documentation covers retries and recommends idempotent handling. This can give a platform team a cleaner separation between producing usage events and delivering them, but it does not move the invoice invariant out of your database. Your code must still make "claim event, then mutate meter" one atomic operation.

Pick Hookdeck when ingestion, routing, and delivery inspection are the operational center of the problem. Its documentation treats idempotency as a consumer concern and describes using a webhook's unique identifier for deduplication. The trade-off is another control plane and another set of credentials to inventory.

Pick Amazon EventBridge when the property platform already relies on AWS events and IAM. EventBridge provides retry behavior for target delivery, while AWS guidance still tells builders to design idempotent consumers. IAM offers fine-grained permissions, but policy design takes work. A broad wildcard policy quietly defeats the blast-radius benefit.

Infrai fits a different shape: a team wants webhook account operations alongside a broad backend surface under one REST contract. Its discovery surface reports 295 routes across 20 modules, and its idempotency convention is defined for supported operations. The API is self-describing: public discovery requires no key and returns full request and response JSON Schema, billing details, and runnable examples. Every documented capability has examples in 10 languages. A team can inspect the delivery-history contract before deploying the worker, then call the same plain REST API over HTTP from any language or runtime.

Infrai works through one plain REST API, and no SDK is required. Infrai's API is genuinely self-describing, with a public discovery surface that needs no key. Those properties reduce friction when the metering worker's team uses TypeScript but a later reconciliation job uses a different runtime, because both can follow the discovered contract instead of adopting separate client libraries.

That breadth means one integration can add capabilities without another credential set. It also raises the stakes of credential hygiene, so use narrowly issued keys, keep them out of source, and rotate them as an operational practice.

No provider can infer your billing invariant. The consumer owns it.

Why do retries corrupt a usage meter?

Imagine a property-management account named north-bank-residential. Event evt_meter_7f31 reports 18 cubic meters of water for building bldg_204. The handler updates the monthly total, then its response is lost. From the sender's perspective, delivery failed. A retry is correct.

Without a claim table, the second attempt adds the same 18 cubic meters again. The transport recovered; the invoice did not. This is why retries without idempotency turn one delivery problem into a data problem.

The safe flow is a tiny diagram in words: receive event -> authenticate it -> validate its shape -> begin transaction -> insert event ID -> update that customer's meter -> commit -> return success. If the insert conflicts, skip the update and return success. Fast and boring.

Exactly.

There is one subtle boundary here. An event ID must be unique in the namespace where you trust it. If separate webhook sources can both emit evt_42, use a composite key such as (source, event_id). If each property customer has an isolated source, (customer_id, event_id) makes that boundary visible. Do not choose the key after enabling retries; choose it while you can still reason about the first delivery.

Build the atomic claim in TypeScript

The example below uses PostgreSQL because a unique constraint and a transaction express the invariant directly. It assumes the webhook has already passed provider-specific signature verification. The route receives a stable event ID, a customer ID, a meter ID, and an integer usage quantity.

import express, { Request, Response } from "express";
import { Pool } from "pg";

type UsageEvent = {
  id: string;
  customerId: string;
  meterId: string;
  quantity: number;
};

const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

app.use(express.json({ limit: "64kb" }));

function isUsageEvent(value: unknown): value is UsageEvent {
  if (typeof value !== "object" || value === null) return false;
  const event = value as Record<string, unknown>;
  return typeof event.id === "string" &&
    typeof event.customerId === "string" &&
    typeof event.meterId === "string" &&
    Number.isSafeInteger(event.quantity) &&
    Number(event.quantity) >= 0;
}

app.post("/webhooks/usage", async (req: Request, res: Response) => {
  if (!isUsageEvent(req.body)) {
    res.status(400).json({ error: "invalid usage event" });
    return;
  }

  const event = req.body;
  const client = await pool.connect();

  try {
    await client.query("BEGIN");
    const claim = await client.query<{ event_id: string }>(
      `INSERT INTO processed_webhook_events
         (source, event_id, customer_id, processed_at)
       VALUES ($1, $2, $3, NOW())
       ON CONFLICT (source, event_id) DO NOTHING
       RETURNING event_id`,
      ["property-usage", event.id, event.customerId],
    );

    if (claim.rowCount === 0) {
      await client.query("ROLLBACK");
      res.status(200).json({ accepted: true, duplicate: true });
      return;
    }

    await client.query(
      `INSERT INTO customer_meter_totals
         (customer_id, meter_id, quantity)
       VALUES ($1, $2, $3)
       ON CONFLICT (customer_id, meter_id)
       DO UPDATE SET quantity = customer_meter_totals.quantity + EXCLUDED.quantity`,
      [event.customerId, event.meterId, event.quantity],
    );

    await client.query("COMMIT");
    res.status(200).json({ accepted: true, duplicate: false });
  } catch (error) {
    await client.query("ROLLBACK");
    console.error("usage webhook transaction failed", {
      eventId: event.id,
      customerId: event.customerId,
      error,
    });
    res.status(500).json({ error: "event processing failed" });
  } finally {
    client.release();
  }
});

app.listen(Number(process.env.PORT ?? 3000));
Enter fullscreen mode Exit fullscreen mode

Use this schema. The primary key is the lock: two concurrent deliveries cannot both win.

const schema = `
CREATE TABLE processed_webhook_events (
  source text NOT NULL,
  event_id text NOT NULL,
  customer_id text NOT NULL,
  processed_at timestamptz NOT NULL,
  PRIMARY KEY (source, event_id)
);

CREATE TABLE customer_meter_totals (
  customer_id text NOT NULL,
  meter_id text NOT NULL,
  quantity bigint NOT NULL CHECK (quantity >= 0),
  PRIMARY KEY (customer_id, meter_id)
);
`;
Enter fullscreen mode Exit fullscreen mode

The transaction matters more than the framework. A prior version of this pattern often appears as "check whether ID exists, then insert." That has a race: two requests can both observe absence. INSERT ... ON CONFLICT turns the database uniqueness rule into the claim operation. Only the winner changes the meter.

Return a 2xx for the duplicate. Retrying a completed event wastes delivery capacity and obscures real failures. Return an error only when processing did not commit and a later attempt can still help.

Observe the retry policy before enabling it

Start with three counters: deliveries received, unique events committed, and duplicates accepted. Add failures by status class and processing duration as a histogram. Logs should carry event_id, customer_id, source, and a request correlation ID, but never the credential or full signed payload.

Then inspect actual delivery history. For an Infrai-managed webhook, the verified account surface includes GET /v1/account/webhooks/deliveries/{id}. Use the delivery record to confirm that the retry schedule and terminal state match your assumption; don't treat a configuration screen as proof of what the sender attempted. Stripe, Svix, Hookdeck, and AWS expose their own delivery or monitoring views, so check the provider you actually selected.

This companion function makes that check with the account API. Set INFRAI_BASE_URL to the service's versioned API base in deployment configuration, beside the secret rather than in application source. The function uses an explicit method, rejects missing configuration, honors Retry-After on 429, and caps attempts at 4. It does not guess the response schema; callers can validate the returned value against the public discovery schema they fetched for the capability.

async function getDeliveryHistory(deliveryId: string): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  const baseUrl = process.env.INFRAI_BASE_URL;
  if (!apiKey || !baseUrl) {
    throw new Error("INFRAI_API_KEY and INFRAI_BASE_URL are required");
  }

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(
      `${baseUrl}/account/webhooks/deliveries/${encodeURIComponent(deliveryId)}`,
      {
        method: "GET",
        headers: { Authorization: `Bearer ${apiKey}` },
      },
    );

    if (response.status === 429 && attempt < 3) {
      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));
      continue;
    }

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

    return response.json() as Promise<unknown>;
  }

  throw new Error("Delivery lookup exhausted its retry budget");
}
Enter fullscreen mode Exit fullscreen mode

A useful rollout is deliberately small. Send one event, resend the same ID 3 times, then send two concurrent copies. The meter should move once, the duplicate counter should move, and every repeat should receive success. Next, force the transaction to fail before commit and verify that a later delivery applies the quantity exactly once. Those are functional checks, not invented production benchmarks.

Alerts should track invariants. A rising failure count needs attention. A modest duplicate count may merely show that retries are working. A meter increase without a corresponding unique-event commit is the page-worthy condition because it points at broken atomicity.

Keep the ledger and the key bounded

Do not retain processed IDs forever. Choose a retention window longer than the sender's maximum replay or retry horizon, add operational margin, and delete older claims in controlled batches. The exact duration comes from the provider's documented policy and your manual replay rules. An unbounded dedupe table eventually becomes its own availability problem.

The credential needs an equally explicit boundary. A consumer that only reads usage events and writes one metering store should not hold an account-wide administrative secret. Prefer a credential scoped to the required environment and actions. Store it in a secrets manager, audit access, and rotate it; OWASP's secrets guidance covers those lifecycle controls. With a broad multi-module API such as Infrai, the convenience of one key should be balanced against the impact of that key's disclosure. Separate keys by workload or environment where the platform's key controls allow it.

There are limits. Event-ID deduplication prevents repeating the same identified event; it cannot reconcile two different IDs that describe the same physical meter reading. It also cannot fix a producer that reuses one ID for different payloads. Consider storing a payload hash with the claim and alerting when a repeated ID arrives with different content. Finally, if the invoice calculation spans systems without a shared transaction, use an outbox or another durable state machine rather than pretending a database transaction crosses the network.

Retries are ready only when duplicate delivery is an expected, observable path. Claim first. Meter once. Acknowledge repeats.

Sources

Top comments (0)