sql java 102 lines · 3 tabs

Reliable Webhook Delivery with a Transactional Outbox and Dedupe Key in Spring Boot

Shared by codesnips Aug 2026
3 tabs
CREATE TABLE outbox_deliveries (
    id              BIGSERIAL PRIMARY KEY,
    dedupe_key      TEXT        NOT NULL,
    target_url      TEXT        NOT NULL,
    payload         JSONB       NOT NULL,
    status          TEXT        NOT NULL DEFAULT 'PENDING',
    attempts        INT         NOT NULL DEFAULT 0,
    next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Enforces idempotency: the same logical event can only ever be enqueued once.
CREATE UNIQUE INDEX uq_outbox_dedupe_key ON outbox_deliveries (dedupe_key);

-- Supports the dispatcher's polling query for due, unfinished rows.
CREATE INDEX ix_outbox_due ON outbox_deliveries (status, next_attempt_at)
    WHERE status IN ('PENDING', 'RETRY');
3 files · sql, java Explain with highlit

This snippet shows how outbound webhook deliveries are made reliable and duplicate-free using the transactional outbox pattern in a Spring Boot service. The core idea is that when domain state changes, the intent to deliver a webhook is written to a database table in the same transaction as the business change, rather than being fired directly over HTTP. That guarantees the outbox row and the state change either both commit or both roll back, closing the window where a crash between the two would drop or double-send an event.

In outbox_deliveries.sql, each row carries a dedupe_key with a unique index, plus status, attempts, and next_attempt_at columns that drive retry scheduling. The unique constraint is what actually enforces idempotency: if the same logical event is enqueued twice, the second insert collides and is silently ignored, so a retried request or a duplicate domain trigger cannot create a second delivery.

WebhookOutbox is the write side. enqueue runs Propagation.MANDATORY, forcing callers to already hold a transaction so the insert joins the caller's unit of work. It uses ON CONFLICT (dedupe_key) DO NOTHING and returns whether a row was actually created, letting callers observe deduplication without failing. Building the dedupe_key from a stable event identity (here the event type and aggregate id) is the crucial design decision — the key must be derived from the event's meaning, not a random UUID, or dedup is impossible.

WebhookDispatchJob is the read side, decoupled in time from the write. A @Scheduled sweep calls claimBatch, which uses FOR UPDATE SKIP LOCKED so multiple instances can poll the same table concurrently without handing the same rows to two workers. Each claimed row is POSTed via RestTemplate; success marks it SENT, failure increments attempts and pushes next_attempt_at forward with exponential backoff, or moves it to FAILED after a cap.

This approach trades immediate delivery latency for durability and exactly-once enqueue semantics with at-least-once delivery, so receivers must still be idempotent. It suits any system where losing or duplicating a webhook is unacceptable, such as billing or provisioning integrations.


Related snips

Share this code

Here's the card — post it anywhere.

Reliable Webhook Delivery with a Transactional Outbox and Dedupe Key in Spring Boot — share card
Link copied