ResourcesEngineeringIdempotent webhooks
Engineering · Playbook
15 min read·March 2026·By the Palla platform team

Building idempotent webhooks that survive everything.

Our internal playbook for at-least-once delivery without customer-side duplication. How Palla signs, retries, orders, dedupes, and replays the ~14M webhook events we deliver every month — and exactly how we expect partners to receive them.

POST /calls · evt_01HQNZ742…
{
  "id":         "evt_01HQNZ7F2A6PGCAT0",
  "type":       "transfer.completed",
  "created_at": "2026-03-04T17:22:11.317Z",
  "sequence":   4827393,
  "livemode":   true,
  "data": {
    "object":   "transfer",
    "id":       "tr_0F36CD7…",
    "status":   "completed",
    "corridor": "US_MX",
    "amount":   24842.00,
    "currency": "USD"
  }
}
palla-signature: t=1741270133,v1=f0b…2d3c200 OK · 64ms
01The contract

Webhooks aren't a delivery problem. They're a distributed-systems problem.

A webhook is the place where two independent services try to agree on what happened, in what order, and exactly once. The internet, the partner's server, our server, and our queue all get a vote — and any of them can lie. The whole point of this guide is what we promise, what we don't, and how to write a receiver that's robust to both.

Most partners discover their webhook handler is broken the first time we have an upstream incident — when our queue finally drains, fires eight events for one transfer in two seconds, and the partner's ledger ends up with eight duplicate credits. That's not a Palla problem. That's a contract problem: we deliver at least once, and the receiver has to be the one that enforces exactly-once effect.

Throughout this guide, “idempotent” means one specific thing: processing the same event twice produces the same observable outcome as processing it once. Not “the request returns the same body” — that's a weaker, less useful definition. The bar is the side-effects in your database, your ledger, and your downstream notifications.

Here's the four things Palla promises you. The rest of the guide is how we deliver each of them — and what your code has to do on the other side to make them stick.

At-least-once
Every event is delivered at least once. We retry until you 2xx — or until a 24-hour cap.
Signed
Every request carries an HMAC-SHA256 signature in palla-signature, with a timestamp to defeat replays.
Ordered per object
Within a single transfer (or userId), events arrive in causal order via a monotonic sequence.
Replayable
Any event in the last 30 days can be re-delivered on demand — from the dashboard or POST /v3/webhooks/{id}/replay.
02The failure modes

Five things that break — every single one of them, in production.

These aren't theoretical. Each of these has shown up in a partner postmortem we've written or read in the last 18 months. The rest of this guide is structured around defending against this list.

01
The duplicate.
We send. Your server processes. Your 200 OK is lost in a TCP reset. We retry, you process again. Customer is credited twice.
Same evt_… processed N times. Ledger drifts.
02
The out-of-order.
transfer.completed retries through the queue and arrives after a transfer.refunded that was sent later. Receiver flips an already-refunded transfer back to completed.
State machine moves backward. Status flickers.
03
The forged event.
Someone posts directly to your webhook URL with a hand-crafted payload. No signature check, no constant-time compare — and your ledger is now a public write endpoint.
Untrusted writes. The worst kind of incident.
04
The thundering retry.
Your server is unreachable for 12 minutes. We hold ~40k events. When you come back, we deliver them in a burst. Your DB connection pool melts.
Recovery causes a second incident.
05
The slow consumer.
Your handler does the work synchronously: writes the ledger, calls 3 vendors, sends an email — all before responding 200. Latency creeps to 12s. We start retrying mid-handler. Now everything is duplicated.
Handler timeouts disguised as duplicates.
03The delivery side

What Palla does on the wire — so you know what to expect.

Every webhook leaving our edge goes through the same pipeline: enqueued at the source of truth (the ledger), signed with a per-endpoint secret, sent with a budget, and retried with exponential jitter for up to 24 hours. Here's the shape, the headers, and the retry curve.

Verify in four steps
1
Read the raw request body — don't parse yet.
Signature verification runs over the exact bytes we sent. JSON.stringify(req.body) re-serializes with different whitespace and breaks every signature. Use express.raw() or its equivalent.
2
Bound the timestamp.
The t= in the signature header is when we signed. Reject anything more than 5 minutes from now — it's either a replay or your clock is broken.
3
Recompute the HMAC over t + "." + rawBody.
SHA-256, the per-endpoint secret. Compare with constant-time equality (timingSafeEqual / hmac.compare_digest). String == leaks secrets via timing.
4
Then — and only then — parse JSON.
Everything downstream (dedup, sequence checks, business logic) trusts the body. The signature check is the gate; it runs first or you have a forged-event problem.
Rotation
Endpoints carry two valid secrets at any time — current and next. Try both. Rotation is roll-forward, never a switch.
HTTP
POST /palla HTTP/1.1
Host:            api.partner.co
Content-Type:    application/json
User-Agent:      Palla/Webhook (+https://docs.platform.palla.app)
Palla-Delivery:  dl_01HQR7K2A6BPDAZQ_03           # attempt #3
Palla-Event:     evt_01HQR7K2A6BPDAZQ
Palla-Sequence:  4827393
Palla-Signature: t=1741270133,v1=f0b34c…2d3c

{
  "id":       "evt_01HQR7K2A6BPDAZQ",
  "type":     "transfer.completed",
  "sequence": 4827393,
  "data":     { /* same payload regardless of attempt */ }
}
Send a real signed event from the dashboard
Open in sandbox →
Retry schedule
11 attempts · exponential w/ jitter · 24h cap
30s10m1h4h24h#1 · instant#2 · 30s#3 · 2m#4 · 8m#5 · 30m#6 · 1h#7#8#9#10#11#
Jitter is ±10% per attempt. Any 2xx ends the chain. After attempt 11 the event is parked in failed — recoverable from the dashboard.
Headers reference
Palla-Event
evt_…
Stable event ID. The dedup key.
Palla-Delivery
del_…
Per-attempt ID. Ends in _01, _02, …
Palla-Sequence
4827393
Causal order within object.
Palla-Signature
t=…,v1=…
Timestamp + HMAC-SHA256.
04The receiver side

Persist before you ack. Process after.

The single most important rule. The receiver's job is split in half: the synchronous half does enough to safely say 200 OK. The asynchronous half does the actual work. Every robust webhook handler we've ever seen looks like this.

Receiver flow — first delivery vs. retry
Same code path. Different outcome.
PallaYour /palla edgePostgresAsync worker1stPOST evt_x (attempt 1)INSERT … ON CONFLICT DO NOTHINGrowCount = 1enqueue webhook.process200 OKretryPOST evt_x (attempt 2 — duplicate)INSERT … ON CONFLICT DO NOTHINGrowCount = 0200 OK (no enqueue)
The handler — the only safe shape
NODE.JS / EXPRESS
app.post("/palla", express.raw({ type: "*/*" }), async (req, res) => {
  // 1. signature — fast fail before you trust the body
  try { verifyPallaSignature(req.body, req.headers["palla-signature"], SECRET); }
  catch { return res.status(401).send("bad sig"); }

  const evt = JSON.parse(req.body);

  // 2. dedup INSERT — the database is the source of truth, not your code
  const { rowCount } = await db.query(`
    INSERT INTO webhook_events (event_id, type, sequence, payload, received_at)
    VALUES ($1, $2, $3, $4, now())
    ON CONFLICT (event_id) DO NOTHING
  `, [evt.id, evt.type, evt.sequence, evt]);

  // 3. ack fast/early — even if it's a duplicate
  res.status(200).end();

  // 4. process async ONLY if we just inserted (rowCount = 1)
  if (rowCount === 1) await queue.publish("webhook.process", evt.id);
});
The schema — three tables, no more
POSTGRES
-- 1. raw landing zone. unique on event_id is the dedup primitive.
CREATE TABLE webhook_events (
  event_id      text        PRIMARY KEY,
  type          text        NOT NULL,
  sequence      bigint      NOT NULL,
  payload       jsonb       NOT NULL,
  received_at   timestamptz NOT NULL,
  processed_at  timestamptz
);

-- 2. transfer projection — what your app actually reads.
CREATE TABLE transfers (
  transfer_id   text PRIMARY KEY,
  status        text NOT NULL,
  applied_seq   bigint NOT NULL DEFAULT 0   -- last sequence applied
);

-- 3. ledger postings — append-only, one row per (event_id, leg).
CREATE TABLE ledger_postings (
  event_id      text,
  leg           text,
  amount        numeric,
  PRIMARY KEY (event_id, leg)
);
Rule 1
Dedup is a database constraint, not application logic.
PRIMARY KEY on event_id, ON CONFLICT DO NOTHING. Anything weaker — a SELECT-then-INSERT, an in-memory cache — has a race that will fire eventually.
Rule 2
Ack before you process.
200 OK means “I durably received this.” Not “I successfully did everything.” Doing the work synchronously turns network blips into duplicates.
Rule 3
Effects are keyed by event_id.
Every side effect — ledger posting, email, downstream API call — is tagged with the event_id. If something replays, the side effect uniquely collides and is skipped.
05Ordering

Sequence numbers are how we promise causal order without promising clocks.

Wall-clock timestamps lie. Across instances, datacenters, and retried events, you cannot trust that an event with a later created_at actually represents a later state. We attach a per-object monotonic sequence to every event — the receiver's job is to ignore anything older than what's already applied.

Apply only forward
POSTGRES
-- guarded update: ignore replays AND out-of-order arrivals.
UPDATE transfers
   SET status      = $1,
       applied_seq = $2,
       updated_at  = now()
 WHERE transfer_id = $3
   AND $2 > applied_seq;     -- key line

-- if 0 rows updated, the event is older than what we have. drop it.
-- this is safe regardless of arrival order.
Same transfer · three arrivals
The third one is older. It gets dropped.
1
seq 4827193
transfer.created
wall: 09:42:01.107
applied
2
seq 4827917
transfer.completed
wall: 09:42:11.467
applied
3
seq 4827195
transfer.queued
wall: 09:42:08.804
dropped
Without sequence guards, arrival #3 would walk the transfer back to queued. With them, it's a no-op.
Combined with the dedup PRIMARY KEY on event_id, a guarded applied_seq < update is enough to handle every duplicate + reorder combination. You don't need a buffer. You don't need to wait for “missing” events. The state machine is monotonic in the sequence number, so older events become no-ops.
The transfer state machine — one direction only
terminalcreatedqueuedsentcompletedrefundedfailed
Status only ever moves rightward (or to failed). Combined with applied_seq, this is sufficient to absorb any reorder.
06Replay & backfill

If your handler is idempotent, replay is just a feature.

The 30-day event store isn't a debugging tool — it's a first-class part of the contract. Backfills, partial replays, and incident recovery all use the same primitive: re-deliver events, your dedup absorbs the duplicates, your sequence guards absorb the reorders.

Scenario 01
Lost a few minutes during a deploy.
Endpoint was 502ing for ~7 minutes. Our retry curve will heal most of it, but anything that hit DLQ needs a manual replay.
$ palla webhooks replay \
    --endpoint we_01HQ… \
    --since 2026-03-15T09:30:00Z \
    --until 2026-03-15T09:40:00Z
Scenario 02
Backfilling a new partner.
Onboarding a partner who wants the last 7 days of history before going live. Replay over a stable window with --filter to scope to the corridor under test.
$ palla webhooks replay \
    --endpoint we_01HR… \
    --since 2026-03-07T00:00:00Z \
    --filter "corridor=US_MX"
Scenario 03
A handler bug ate a class of events.
Receiver shipped a regression that 200d everything but only persisted half. Fix the bug, ship, then replay only the affected event types into the same endpoint — dedup keeps the rest safe.
$ palla webhooks replay \
    --types transfer.completed,transfer.refunded \
    --since 2026-03-17T00:00:00Z
What replay does NOT do
Replay does not change the event payload. It does not re-sign with a fresh timestamp (we ship the original to a fresh v1), so receivers that enforce the 5-minute window MUST allow a replay grace via the Palla-Replay: true header. Treat that header as a signed assertion from us — never trust it without the signature check.
07What we watch

The SLOs we ship to — and the four numbers you should put on a dashboard.

We publish our edge-side numbers to the status page. The receiver-side numbers are yours to own. If you only graph four things, graph these — they catch ~95% of the incidents we've ever seen partners hit.

Palla edge-side SLOs (published)
p99 deliver
≤ 1.2s
From ledger commit to first POST.
p99 deliver
≤ 8s
Including one queued retry.
Dup rate (p99)
< 0.4%
Of total delivered events that are second+ attempts.
Out-of-order
< 0.05%
Per object — measured by sequence inversions per transfer.
These are bounds, not averages. Across the last quarter we've delivered ~14M events/month with a p99 deliver time of 6.4s and a partner-observed dup rate of 0.27%. If you see numbers meaningfully outside these bands, our status page or your signature check are usually the cause — in that order.
The four (and a half) numbers on your side
01
Ack latency p99 < 500 ms (response time of your /palla endpoint).
02
Insert-vs-process gap (rows in webhook_events with NULL processed_at).
03
Sequence inversions seen — should be near zero in steady state.
04
Dedup hit rate — should track Palla's published retry rate, ±0.2%.
05
Signature failures — should be zero. Anything else is an attack or a key-rotation bug.
The TL;DR if you skim engineering blogs
Verify the signature. Insert the event_id into a table with a PRIMARY KEY. Ack 200 immediately. Process asynchronously, keyed by the event_id. Apply state transitions only when sequence > applied_seq. Everything else is detail.

Want our team to review your handler
before launch?

We'll do a 30-minute pairing session against your sandbox endpoint — checking signature verification, dedup, and your replay strategy. Free for partners on the integration plan.