Skip to main content

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.

Two halves: outbound, where an Idempotency-Key decides between a replay, a 409 conflict and a second effect; and inbound, where an INSERT ... ON CONFLICT DO NOTHING decides whether an event has already been handled.

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" }'
ReplayResult
Same key, same bodyThe original response, including the original id. No second effect
Same key, different body409 IDEMPOTENCY_CONFLICT. Nothing happens
No keyA 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

RequestWithout a key
POST /v1/transactionsFunds sent twice. Irreversible on every chain here
POST /v1/addressesA second address for one customer. You monitor two, they have one
POST /v1/webhooksA 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