A customer updates an order. Your application sends a webhook to three partner systems. Two respond successfully; the third times out. Five minutes later, support asks whether that partner received the update, what was sent, and whether retrying will create a duplicate.

A single webhook_sent flag cannot answer those questions. Webhook database design needs to separate the event, each intended delivery, and every network attempt. That separation gives retries, troubleshooting, and reporting a clear place in the schema.

The core model: one event can have many deliveries, and one delivery can have many attempts. Retrying a delivery should preserve its identity.

The four tables behind a webhook database schema

This guide models outbound webhooks: your application produces events and sends them to registered customer endpoints. Start with four entities before adding subscription filters, secret rotation, or retention policies.

A minimal outbound webhook data model
TableOne row representsImportant fields
webhook_eventsA business event that occurredid, event_type, payload, occurred_at
webhook_endpointsA registered delivery destinationid, url, enabled, signing_secret_ref
webhook_deliveriesAn event assigned to one endpointevent_id, endpoint_id, status, next_attempt_at
webhook_attemptsOne attempt to send a deliverydelivery_id, attempt_number, started_at, http_status, error_code

The relationships are straightforward: events have many deliveries, endpoints have many deliveries, and deliveries have many attempts. The deliveries table connects events and endpoints while storing the state of that specific delivery.

These are conceptual fields, not a complete production schema. For a multi-tenant product, add account ownership and enforce tenant-consistent event-to-endpoint relationships before using this design.

Give the event and the delivery different identities

Suppose event evt_204 describes order.updated. It must reach an accounting endpoint and a warehouse endpoint. Those are two deliveries of the same event. A warehouse timeout creates another attempt for the warehouse delivery, not a new business event.

  • Event ID: identifies what happened and stays stable across destinations.
  • Delivery ID: identifies one event-to-endpoint assignment and stays stable across retries.
  • Attempt ID: identifies one network operation and changes on every attempt.

Include the stable event ID in the message body and a stable delivery ID in your documented delivery header. A receiver can choose an appropriate deduplication key for its consumer scope. If integrations also use external identifiers, keep them separate from internal primary keys, as explained in our guide to external IDs in database design.

Use constraints to prevent duplicate delivery assignments

A worker can accidentally schedule the same event for the same endpoint twice. An application-level lookup helps, but two concurrent workers can both pass that lookup. A database uniqueness rule closes that race.

The following PostgreSQL example defines the delivery and attempt tables. It assumes webhook_events and webhook_endpoints already exist with bigint primary keys. It illustrates relational constraints; it does not implement the worker.

CREATE TABLE webhook_deliveries (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  event_id bigint NOT NULL REFERENCES webhook_events(id),
  endpoint_id bigint NOT NULL REFERENCES webhook_endpoints(id),
  status text NOT NULL DEFAULT 'pending'
    CHECK (status IN ('pending', 'processing', 'succeeded', 'failed')),
  next_attempt_at timestamptz NOT NULL DEFAULT now(),
  delivered_at timestamptz,
  UNIQUE (event_id, endpoint_id)
);

CREATE TABLE webhook_attempts (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  delivery_id bigint NOT NULL REFERENCES webhook_deliveries(id),
  attempt_number integer NOT NULL CHECK (attempt_number > 0),
  started_at timestamptz NOT NULL DEFAULT now(),
  finished_at timestamptz,
  http_status integer CHECK (http_status BETWEEN 100 AND 599),
  error_code text,
  UNIQUE (delivery_id, attempt_number)
);

CREATE INDEX webhook_deliveries_due_idx
  ON webhook_deliveries (next_attempt_at, id)
  WHERE status = 'pending';

Use the same delivery for ordinary retries and operator redelivery in this model. If your product requires separate replay jobs, model them explicitly instead of silently removing the uniqueness constraint. See our guide to database constraints for the broader design vocabulary, and the PostgreSQL constraint documentation for engine-specific behavior.

Record each retry without overwriting its history

A mutable last_error field is convenient for a dashboard, but it cannot explain the full delivery history. Preserve attempt records so support can distinguish a connection failure, an HTTP rejection, and a later successful response.

  1. Claim a due delivery atomically. Lock or conditionally update the row so workers do not both own it. Allocate the attempt number under the same protection.
  2. Record the attempt before sending. Commit the claim, then perform the network request outside a long-running database transaction.
  3. Finalize the outcome. Store the response status or transport error. Mark success, schedule a retry, or move to terminal failure according to a documented policy.

Retry transient failures with bounded exponential backoff and jitter. Define maximum attempts, delivery age, and how rate-limit responses affect scheduling. Treat configuration and authentication failures according to your API contract rather than retrying every failure forever.

Production workers also need recoverable leases, such as locked_until and a claim token. An expired lease lets another worker recover abandoned work; the token prevents an old worker from overwriting a newer claim. Recovery can still create overlapping network attempts, so receiver idempotency remains necessary.

Close the gap between saving data and scheduling a webhook

Imagine saving the order successfully, then crashing before creating its event. The order changed, but no webhook exists to deliver. Reversing the order introduces a different risk: a notification may describe a transaction that later rolls back.

Use a transactional outbox when the business change and event record share a transactional database. Save both in the same transaction, then let a separate dispatcher create delivery assignments after commit. For this design, webhook_events can act as the outbox if it also tracks dispatch progress. The dispatcher must retry fan-out safely and rely on the event-to-endpoint uniqueness constraint.

This follows the purpose of the transactional outbox pattern documented by AWS. It makes the local database change and event creation atomic. It does not make the remote HTTP request part of that transaction.

Preserve what you promised to send

An order.updated event should have a defined payload contract. If every retry rebuilds its body from the latest order row, the same event ID may describe different data on different attempts.

For a snapshot-style contract, persist the versioned event payload once and reuse it. This applies the distinction between current state and historical facts covered in snapshot data modeling. A reference-only event can also work, but explicitly document that consumers must fetch current state.

Decide what happens when an endpoint URL changes while deliveries are pending. Version the endpoint configuration, or snapshot the validated destination used by the delivery. Keep secrets in a protected secret store or encrypted storage, and record a key version or reference rather than exposing signing credentials in attempt logs.

Why the schema cannot guarantee exactly-once effects

The receiver may process a request successfully while its response is lost. Your sender sees a timeout and retries. Both observations are valid: the request succeeded remotely, and the sender never received confirmation.

That is why a delivery record marked successful is different from a guarantee that a business operation happened exactly once. Stripe’s webhook documentation, for example, tells consumers to handle duplicate events and warns that event delivery order is not guaranteed.

For database-local receiver effects, insert the consumer’s deduplication record and apply the business change in one transaction, protected by a unique key. When processing requires another external service, pass a stable idempotency key where supported and design recovery explicitly. A “processed” flag written before an external call can lose work; one written afterward can allow duplicates.

Ordering needs its own contract too. If an older update must never replace newer state, use a meaningful resource version and conditional application, or fetch authoritative current state. Do not assume event timestamps alone establish a reliable sequence.

Review the webhook schema before implementation

  • Can one event reach several endpoints without duplicating the event?
  • Can support inspect every attempt and identify the next eligible retry?
  • Can a worker recover after crashing before or after an HTTP request?
  • Are payload versions, endpoint changes, and replay behavior defined?
  • Do tenant boundaries and constraints prevent cross-account deliveries?
  • Are signature verification, outbound URL validation, retention, and sensitive-data redaction part of the implementation plan?

Sketch these relationships in DB Designer before writing the dispatcher. Walk through one successful delivery, one timeout, and one crash recovery. A clear ER diagram helps the team see where identities, state, and history belong—and which rules still need to be decided.