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');
@Repository
public class WebhookOutbox {
private final NamedParameterJdbcTemplate jdbc;
public WebhookOutbox(NamedParameterJdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional(propagation = Propagation.MANDATORY)
public boolean enqueue(String eventType, String aggregateId, String targetUrl, String payloadJson) {
String dedupeKey = eventType + ":" + aggregateId;
MapSqlParameterSource params = new MapSqlParameterSource()
.addValue("dedupeKey", dedupeKey)
.addValue("targetUrl", targetUrl)
.addValue("payload", payloadJson);
int inserted = jdbc.update(
"INSERT INTO outbox_deliveries (dedupe_key, target_url, payload) " +
"VALUES (:dedupeKey, :targetUrl, CAST(:payload AS jsonb)) " +
"ON CONFLICT (dedupe_key) DO NOTHING",
params);
// false means the event was already enqueued (deduplicated).
return inserted == 1;
}
}
@Component
public class WebhookDispatchJob {
private static final int BATCH_SIZE = 50;
private static final int MAX_ATTEMPTS = 8;
private final NamedParameterJdbcTemplate jdbc;
private final RestTemplate http;
public WebhookDispatchJob(NamedParameterJdbcTemplate jdbc, RestTemplate http) {
this.jdbc = jdbc;
this.http = http;
}
@Scheduled(fixedDelay = 2000)
public void dispatch() {
for (Delivery d : claimBatch()) {
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.add("X-Idempotency-Key", d.dedupeKey());
http.postForEntity(d.targetUrl(), new HttpEntity<>(d.payload(), headers), Void.class);
markSent(d.id());
} catch (RestClientException ex) {
reschedule(d);
}
}
}
@Transactional
protected List<Delivery> claimBatch() {
return jdbc.query(
"UPDATE outbox_deliveries SET status = 'CLAIMED' WHERE id IN (" +
" SELECT id FROM outbox_deliveries " +
" WHERE status IN ('PENDING','RETRY') AND next_attempt_at <= now() " +
" ORDER BY next_attempt_at FOR UPDATE SKIP LOCKED LIMIT :limit" +
") RETURNING id, dedupe_key, target_url, payload",
new MapSqlParameterSource("limit", BATCH_SIZE),
(rs, i) -> new Delivery(rs.getLong("id"), rs.getString("dedupe_key"),
rs.getString("target_url"), rs.getString("payload")));
}
private void markSent(long id) {
jdbc.update("UPDATE outbox_deliveries SET status = 'SENT' WHERE id = :id",
new MapSqlParameterSource("id", id));
}
private void reschedule(Delivery d) {
String sql = "UPDATE outbox_deliveries SET attempts = attempts + 1, " +
"status = CASE WHEN attempts + 1 >= :max THEN 'FAILED' ELSE 'RETRY' END, " +
"next_attempt_at = now() + (power(2, attempts) * interval '1 second') " +
"WHERE id = :id";
jdbc.update(sql, new MapSqlParameterSource("id", d.id()).addValue("max", MAX_ATTEMPTS));
}
public record Delivery(long id, String dedupeKey, String targetUrl, String payload) {}
}
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
timestamp = request.headers.fetch('X-Signature-Timestamp')
signature = request.headers.fetch('X-Signature')
payload = request.raw_post
data = "#{timestamp}.#{payload}"
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('WEBHOOK_SECRET'), data)
HMAC signed API requests for webhook and partner integrity
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
Share this code
Here's the card — post it anywhere.