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.
palla-signature, with a timestamp to defeat replays.sequence.POST /v3/webhooks/{id}/replay.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.
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.
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.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.
event_id, ON CONFLICT DO NOTHING. Anything weaker — a SELECT-then-INSERT, an in-memory cache — has a race that will fire eventually.event_id.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.
queued. With them, it's a no-op.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.applied_seq, this is sufficient to absorb any reorder.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.
$ palla webhooks replay \
--endpoint we_01HQ… \
--since 2026-03-15T09:30:00Z \
--until 2026-03-15T09:40:00Z$ palla webhooks replay \
--endpoint we_01HR… \
--since 2026-03-07T00:00:00Z \
--filter "corridor=US_MX"$ palla webhooks replay \
--types transfer.completed,transfer.refunded \
--since 2026-03-17T00:00:00ZPalla-Replay: true header. Treat that header as a signed assertion from us — never trust it without the signature check.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.
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.