2026-10-04

Idempotent Payment Webhook Handlers: How to Stop Double Credits

A payment webhook is not a receipt. It's a delivery attempt. Networks time out, workers restart mid-request, and a provider retries whenever it can't prove your endpoint accepted the event. Treat idempotency as something to add later and the first duplicate delivery turns one USDC payment into two account credits, two fulfillment jobs, or a balance nobody can explain.

We've covered the webhook flow itself: what the payload carries, how signatures work, why delivery is at least once. This post is about the handler on the other end. Your job is to take an at-least-once stream and make the business effect happen exactly once.

Two identities, two jobs

An operation is idempotent when running it twice leaves the same final state as running it once. That doesn't mean the handler ignores repeats. It means a repeat finds the work already recorded and returns without issuing another credit, shipment, or email.

The question that matters isn't "have we seen this webhook before?" It's "has this payment already changed our system of record?" Those have different answers, so they need different keys.

The event id deduplicates transport. Every Klappay delivery carries an id (ev_...) that stays the same across every retry of that delivery, including a manual retryDelivery() through the API or SDK. Store it, and a retried POST becomes a no-op.

The charge id deduplicates the business effect. The event ID is not the identity of the payment, and three ordinary situations prove it:

  • Two endpoints, one fact. Klappay builds a separate payload, with its own id, for every webhook subscribed to an event. While you migrate an endpoint URL and briefly have both registered, the same charge.confirmed reaches your service twice under two different IDs.
  • Two events, one payment. When a payer sends more than the charge asked for, charge.overpaid fires alongside charge.confirmed. Both carry the full charge, and a handler that credits on "any event saying the charge is paid" credits twice.
  • Several transfers, one charge. A charge can be paid across multiple transfers: it moves through partially_paid until the cumulative amountReceived covers the amount. txHash only holds the most recent transfer, so it's a poor key for "this order got paid."

Klappay already does the chain-level matching. Every charge has its own deterministic address, so there's no transaction-hash-plus-log-index bookkeeping left for you to do. What's left is yours: one charge, one business effect.

Separate what arrived from what it means

Don't let a webhook payload mutate a customer balance directly. Record what arrived first, then decide whether it's a valid transition for a charge you recognize.

A practical model uses three tables:

  • webhook_events — every accepted delivery: its id, event type, charge ID, and the raw payload.
  • charge_state — your latest view of each charge: status, settlementStatus, amountReceived, timestamps.
  • ledger_entries — the business consequences: an order marked paid, a balance credited, a seller's share recorded.

This split pays for itself during an incident. You can tell whether Klappay retried, whether two events described the same charge, and whether your own worker posted the entry, without reconstructing it from logs. Collapse all three into a single paid = true column and reconciliation turns into archaeology.

Put the guarantee in the database

An in-memory cache or a "have we processed this?" lookup isn't enough. Two workers can both run the lookup before either writes. Our own webhook walkthrough shows that read-then-insert pattern because it's easy to read, and under concurrent retries it's exactly the gap that lets a duplicate through. Idempotency needs a constraint that makes the duplicate impossible to store:

create table webhook_events (
id text primary key,
event text not null,
charge_id text not null,
payload jsonb not null,
received_at timestamptz not null default now()
);
create table ledger_entries (
id bigserial primary key,
charge_id text not null,
entry_type text not null,
order_ref text not null,
amount numeric(30, 18) not null,
unique (charge_id, entry_type)
);

The primary key on webhook_events says the same delivery is stored once. The unique pair on ledger_entries says a charge can create one entry of a given type. If a single payment legitimately produces several entries, say a customer credit and an affiliate commission, give them distinct, intentional entry types. Don't weaken the index because split accounting exists.

Store amounts as exact decimals. The charge carries amountExact and amountReceivedExact as strings precisely so you never route money through a JavaScript float.

A handler that survives retries

Verify the signature before anything else, against the raw body. constructEvent() checks the X-Klappay-Signature HMAC, rejects stale timestamps (five minutes by default), and parses the payload into a typed event. Authentication stops forged callbacks. It does nothing about duplicates, so that part comes next:

import express from 'express'
import { Pool } from 'pg'
import { createClient } from '@klappay/node'
import type { Charge } from '@klappay/types'
const klap = createClient()
const pool = new Pool()
const app = express()
app.post('/webhooks/klappay', express.raw({ type: 'application/json' }), async (req, res) => {
let event
try {
event = klap.webhooks.constructEvent(
req.body.toString('utf8'),
req.header('x-klappay-signature') ?? '',
process.env.KLAP_WEBHOOK_SECRET ?? '',
)
} catch {
return res.sendStatus(400)
}
if (event.event === 'charge.confirmed') {
await recordConfirmedCharge(event.id, event.data)
}
res.sendStatus(200)
})

The work happens in one database transaction:

async function recordConfirmedCharge(eventId: string, charge: Charge) {
const db = await pool.connect()
try {
await db.query('begin')
const delivery = await db.query(
`insert into webhook_events (id, event, charge_id, payload)
values ($1, 'charge.confirmed', $2, $3)
on conflict (id) do nothing`,
[eventId, charge.id, charge],
)
if (delivery.rowCount === 0) {
await db.query('rollback')
return
}
const entry = await db.query(
`insert into ledger_entries (charge_id, entry_type, order_ref, amount)
values ($1, 'order_paid', $2, $3)
on conflict (charge_id, entry_type) do nothing`,
[charge.id, charge.externalRef, charge.amountExact ?? String(charge.amount)],
)
if (entry.rowCount === 1) {
await db.query(
`insert into outbox (charge_id, message_type) values ($1, 'fulfill_order')
on conflict (charge_id, message_type) do nothing`,
[charge.id],
)
}
await db.query('commit')
} catch (err) {
await db.query('rollback')
throw err
} finally {
db.release()
}
}

Two on conflict do nothing clauses, each answering a different question. The first catches a retried delivery. The second catches a different delivery describing a charge that's already been booked. Either way the handler returns 200, because a duplicate is the expected path, not an error, and a non-2xx response only buys you another retry of the same thing. Log it at info level and move on.

The ledger write and the outbox row commit together or not at all. Mark the order paid in one transaction and queue fulfillment in another, and a crash between the two leaves a gap that only a repair job can close. Repair jobs are where duplicate credits like to show up.

Never let a late event move state backwards

Delivery order isn't state order. A charge.partially_paid delayed by a retry can arrive after the charge.confirmed that superseded it. When you update charge_state, only allow forward moves:

insert into charge_state (charge_id, status, settlement_status, updated_at)
values ($1, $2, $3, now())
on conflict (charge_id) do update
set status = excluded.status,
settlement_status = excluded.settlement_status,
updated_at = now()
where charge_state.status not in ('confirmed', 'expired', 'underpaid');

confirmed, expired and underpaid are terminal statuses. Once a charge reaches one, nothing a late event says should overwrite it. Settlement moves on its own axis: settlementStatus goes from pending to completed or failed, and a charge.settled event can only ever move it to completed.

When an event looks wrong for the state you hold, don't guess. klap.charges.get(chargeId) returns the canonical charge, and reconciling against it beats inventing a tiebreak rule in your handler.

Side effects belong in an outbox

Database idempotency doesn't protect anything outside the database. Send an email or call a fulfillment API inside the transaction, and a rollback can't unsend it. Send it after commit, and the process can die before the call goes out.

That's why the handler above writes an outbox row instead of calling anything. Give the table its own uniqueness rule, unique (charge_id, message_type). A separate worker picks up unsent rows, does the work, and marks them sent. It can retry safely as long as the downstream call carries an idempotency key derived from the same stable identity: fulfill:ch_01J... and not a random UUID generated per attempt.

The outbox is where facts about money stop and best-effort operational work starts. "Order 8421 is paid" belongs in a transactionally protected ledger. The Slack notification doesn't.

Decide which event books what

Klappay fixes the confirmation depth per network, so there's no per-charge threshold to tune in your handler. The decision that's actually yours is which event books which entry:

  • charge.confirmed: the payment landed on-chain at the charge's address. Right for granting access to a digital product or marking an invoice paid.
  • charge.settled: the payout reached your wallet (settlementStatus: completed). Right for anything that should wait until the money is in hand, like releasing a high-value shipment or recording revenue.
  • charge.underpaid and charge.overpaid: the amount didn't match. Never route these into the success path. An underpaid charge that gets fulfilled automatically isn't a lenient integration, it's an accounting bug. Klappay doesn't refund overpayments automatically either, so the excess needs its own entry type and a decision from someone.

Pick a rule per entry type and write it down. The unique constraint keeps you safe no matter which event arrives first.

Test the failures you expect

A happy-path test proves almost nothing here. The cases that matter:

  • The same signed payload, delivered ten times in parallel.
  • charge.confirmed and charge.overpaid for the same charge, in both orders.
  • A worker crash after the event insert, after the ledger insert, and after the outbox insert.
  • A charge.partially_paid arriving after charge.confirmed.
  • An underpaid and an expired charge, neither of which should ever produce an order_paid entry.

The sandbox pushes a test charge into any of those states, klap.sandbox.confirm(), overpay(), underpay(), expire(), settle(), and Klappay delivers the matching webhook to your endpoint. For concurrency, sign one body yourself and fire it in parallel:

import { createHmac } from 'node:crypto'
const body = JSON.stringify(payload)
const t = Math.floor(Date.now() / 1000)
const v1 = createHmac('sha256', secret).update(`${t}.${body}`).digest('hex')
await Promise.all(
Array.from({ length: 10 }, () =>
fetch(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-klappay-signature': `t=${t},v1=${v1}` },
body,
}),
),
)
const { rows } = await pool.query('select count(*) from ledger_entries where charge_id = $1', [chargeId])
expect(Number(rows[0].count)).toBe(1)

Run it against a real test database. Mocks rarely reproduce the race you're trying to prevent.

One path, many channels

Klappay can tell your backend about a payment through webhooks, the per-charge event stream, or SDK calls like waitForConfirmation(). Those channels should all end up in the same handler and hit the same constraints. More than one way to observe settlement helps availability. It's not a reason to keep more than one source of truth.

Build for duplicates before the first real payment arrives. Direct settlement means the money moves on-chain to wallets you control, and an idempotent handler means your books stay as trustworthy as the chain they describe.