Idempotency
Two obligations, in opposite directions, and integrations get bitten by both.
Outbound: you retry a request whose response you never saw, and ChainOS must not perform the
effect twice. That is what Idempotency-Key is for.
Inbound: ChainOS delivers a webhook at least once, and you must not perform the effect twice.
That is what event.id is for.

Requests you send
Send an Idempotency-Key header on any state-changing request you might retry.
curl -X POST $EDGE/transactions \
-H "X-API-Key: $CHAINOS_EDGE_KEY" \
-H "Idempotency-Key: payout-99118" \
-H "Content-Type: application/json" \
-d '{ "chain": "eth", "from": "0x3fC9…", "to": "0x8a1c…", "amount": "1000000" }'
| Replay | Result |
|---|---|
| Same key, same body | The original response, including the original id. No second effect |
| Same key, different body | 409 IDEMPOTENCY_CONFLICT. Nothing happens |
| No key | A second effect. A second address, or a second transaction |
The conflict case is the valuable one. A key reused with a different body means your code has a bug — two different payouts collided on one key, or a retry mutated the payload — and a 409 is a much better outcome than silently returning the first payout's result for the second payout's request.
Choosing a key
Use an identifier you already have and that is naturally unique to the intent:
payout-99118 a row id from your payouts table
addr-cust_88213-eth one address per customer per chain
wd-2026-08-19-cust_88213-3 if you genuinely allow repeats, number them
Do not use a random UUID generated at the call site. A retry generates a new one and you get the effect twice, which is precisely the failure the header exists to prevent. The key must be stable across retries of the same intent, which means it has to come from something durable.
Where it matters most
| Request | Without a key |
|---|---|
POST /v1/transactions | Funds sent twice. Irreversible on every chain here |
POST /v1/addresses | A second address for one customer. You monitor two, they have one |
POST /v1/webhooks | A duplicate subscription, so every event is delivered twice |
The first row is why the header exists. The second is the one that actually happens more often, because address creation feels harmless to retry.
Events you receive
Webhook delivery is at-least-once. A duplicate is a documented normal case, not a defect: it happens after a consumer-group rebalance, after an Edge reconnects and drains its outbox, and after an operator replays from the dead-letter queue.
Deduplicate on event.id. It is stable across every redelivery of the same event.
CREATE TABLE chainos_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
payload JSONB NOT NULL
);
@Transactional
public void handle(WebhookEvent evt) {
// The insert IS the lock. Two concurrent deliveries of the same event race here,
// and exactly one wins — the primary key decides, not the application.
int inserted = jdbc.update("""
INSERT INTO chainos_events (event_id, event_type, payload)
VALUES (?, ?, ?::jsonb)
ON CONFLICT (event_id) DO NOTHING
""", evt.id(), evt.event(), evt.rawJson());
if (inserted == 0) {
log.debug("duplicate delivery {}", evt.id());
return; // already handled. Ack and move on.
}
apply(evt); // the ledger write, in the SAME transaction
}
Two things about that shape are load-bearing.
The dedup row and the effect are in one transaction. Insert-then-commit-then-apply leaves a window where the row says "handled" and the credit never happened — and because the row exists, the redelivery is discarded and the credit never happens at all. Silent, and impossible to spot from the outside.
The unique constraint does the mutual exclusion. Not a SELECT followed by an INSERT, which
races: two concurrent deliveries both see no row, both proceed, both credit. Let the database
decide.
Reply 200 to a duplicate
A duplicate is not an error. Return 2xx. A 4xx or 5xx puts the event back on the retry ladder, so it comes back in two minutes, is recognised as a duplicate again, is rejected again, and after three attempts dead-letters — leaving an operator investigating a queue full of events that were handled correctly the first time.
What is not deduplicated for you
deposit_confirmed fires once per threshold. Bitcoin has three thresholds, so three
deposit_confirmed events arrive for one deposit — with different event.ids, because they are
different events. Deduplicating on txid alone would drop two of them; deduplicating on
event.id handles it correctly. If your ledger credits per deposit rather than per confirmation
level, key your credit on (txid, outputIndex) and let the event dedup be separate.
A reorg-and-replay does not re-fire a threshold that already fired. That is tracked
server-side in a bitmask, so you will not see a second deposit_confirmed at the same threshold
for the same transaction.
Ordering across addresses is not guaranteed. Per address it is. Do not build logic that assumes event A for customer X arrived before event B for customer Y because it was generated first.
Testing it
Do not assume your handler is idempotent. Prove it:
# 1. Simulate a deposit in sandbox.
curl -X POST $EDGE/sandbox/simulate-deposit -H "X-API-Key: $CHAINOS_EDGE_KEY" \
-H "Content-Type: application/json" \
-d '{ "address": "0x3fC9…7FAD", "chain": "eth", "amount": "10.00", "autoConfirm": true }'
# 2. Note the balance your ledger shows.
# 3. Replay the delivery from the console, or POST the exact same body to your
# endpoint with the same signature header.
# 4. The balance must be identical.
Step 4 failing is the single most expensive integration bug in this product, and it is trivially cheap to find in sandbox — where nothing is billed and nothing is real.
Further reading
- Webhooks — signature verification and the retry ladder.
- Reconciliation — catching what slipped through anyway.
- API conventions — the header on other endpoints.