Idempotency at the ledger boundary
How webhook retries silently double-book your fintech.
By Solomon Ajayi · Free to read, no signup
Your provider's webhook delivers a ₦10,000 deposit event. Five seconds later, due to a network blip, the provider retries, same event, same payload. Your ledger inserts it AGAIN. The user's wallet shows ₦20,000. This is the bug every fintech ships in v1 and discovers in production. The fix is one column with a unique constraint, but you have to know to add it.
Networks lose acknowledgements, so every serious webhook system delivers at-least-once, not exactly-once. The provider sends your deposit event, your handler posts the entry and commits, but the success response never makes it back. As far as the provider knows, delivery failed, so it retries. The same event arrives again, and a handler that just inserts will happily post a second identical entry.
Your database has no idea the second insert is a duplicate, because nothing in the schema says these two events are the same thing. The user's wallet now reads ₦20,000 against ₦10,000 of real cash, and every report, reconciliation, and refund built on that balance is wrong by ₦10,000. Worse, this is silent. Nothing throws, nothing logs, and the gap usually surfaces months later in front of an auditor.
The fix is structural, not procedural. You store the provider's event ID in an idempotency_key column and put a unique index on it, so the duplicate INSERT fails at the database layer no matter how the retry races with the original. Your handler catches the unique-violation, treats it as already done, and acknowledges. Pushing the dedup down into a constraint is what makes it correct under concurrency, where an application-level check-then-insert would still let two parallel retries slip through.
Worked example, step by step
First webhook delivery (correct)
₦10,000 deposit event arrives. You post the entry. User wallet shows ₦10,000. Bank Account shows ₦10,000. All correct.
| Account | Debit | Credit |
|---|---|---|
| Bank Account (1200) | ₦10,000.00 | |
| User Wallet (2000) | ₦10,000.00 |
Standard deposit shape. Bank UP, User Wallet UP. Same amount. Nothing surprising here, this is what we want.
The schema you wish you'd written from day one
CREATE TABLE journal_entry (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
description text NOT NULL,
posted_at timestamptz NOT NULL DEFAULT now(),
idempotency_key text -- e.g. the provider's event_id
);
-- The single line of schema that prevents double-booking.
-- Partial index allows NULL for manually-posted entries.
CREATE UNIQUE INDEX journal_entry_idem_uniq
ON journal_entry (idempotency_key)
WHERE idempotency_key IS NOT NULL;Plain TypeScript with the pg driver. No ORM.
import { Client } from "pg";
export async function postDeposit(
client: Client,
args: { eventId: string; amount: bigint },
): Promise<void> {
try {
await client.query("BEGIN");
const { rows } = await client.query<{ id: string }>(
`INSERT INTO journal_entry (description, idempotency_key)
VALUES ($1, $2)
RETURNING id`,
[`Deposit (event: ${args.eventId})`, args.eventId],
);
const entryId = rows[0].id;
await client.query(
`INSERT INTO journal_line (entry_id, account_code, debit, credit) VALUES
($1, '1200', $2, 0),
($1, '2000', 0, $2)`,
[entryId, args.amount],
);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
}
}Posting the entry with psycopg. Plain parametrised SQL.
import psycopg
def post_deposit(conn: psycopg.Connection, *, event_id: str, amount: int) -> None:
with conn.transaction():
entry_id = conn.execute(
"""
INSERT INTO journal_entry (description, idempotency_key)
VALUES (%s, %s)
RETURNING id
""",
(f"Deposit (event: {event_id})", event_id),
).fetchone()[0]
conn.execute(
"""
INSERT INTO journal_line (entry_id, account_code, debit, credit)
VALUES (%s, '1200', %s, 0),
(%s, '2000', 0, %s)
""",
(entry_id, amount, entry_id, amount),
)Same shape in Go with database/sql + pgx.
func PostDeposit(ctx context.Context, db *sql.DB, eventID string, amount int64) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var entryID string
err = tx.QueryRowContext(ctx, `
INSERT INTO journal_entry (description, idempotency_key)
VALUES ($1, $2)
RETURNING id
`, fmt.Sprintf("Deposit (event: %s)", eventID), eventID).Scan(&entryID)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `
INSERT INTO journal_line (entry_id, account_code, debit, credit)
VALUES ($1, '1200', $2, 0),
($1, '2000', 0, $2)
`, entryID, amount)
if err != nil {
return err
}
return tx.Commit()
}Webhook retries: the bug fires
Five seconds later, the SAME webhook fires again (network blip, provider retry policy). Your code happily inserts a SECOND identical entry. The user wallet now shows ₦20,000. The database does not know this is a duplicate. The user does not actually have ₦20,000.
| Account | Debit | Credit |
|---|---|---|
| Bank Account (1200) | ₦10,000.00 | |
| User Wallet (2000) | ₦10,000.00 |
Identical entry. Look at the T-accounts: ₦20,000 each side. This is now WRONG state. Every report, every reconciliation, every refund will be off by ₦10,000 until this is found, usually by an auditor, three months later, in front of a regulator.
Idempotent webhook handler. Catch unique_violation, ack, move on.
import psycopg
from psycopg import errors
def handle_deposit_webhook(conn, event):
try:
with conn.transaction():
entry_id = conn.execute(
"""
INSERT INTO journal_entry (description, idempotency_key)
VALUES (%s, %s)
RETURNING id
""",
(f"Deposit (event: {event.id})", event.id),
).fetchone()[0]
conn.execute(
"""
INSERT INTO journal_line (entry_id, account_code, debit, credit)
VALUES (%s, '1200', %s, 0),
(%s, '2000', 0, %s)
""",
(entry_id, event.amount, entry_id, event.amount),
)
except errors.UniqueViolation:
# Duplicate webhook delivery. Already posted. Ack and return.
returnSame idea in Go. Inspect the pg error code on the way out.
import (
"errors"
"github.com/jackc/pgx/v5/pgconn"
)
func HandleDepositWebhook(ctx context.Context, db *sql.DB, ev DepositEvent) error {
err := PostDeposit(ctx, db, ev.ID, ev.Amount)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
// unique_violation: this event was already posted. Ack the webhook.
return nil
}
return err
}The cleanup (and the real fix)
You discovered the duplicate. You post a counter-entry to undo it. User wallet back to ₦10,000.
| Account | Debit | Credit |
|---|---|---|
| User Wallet (2000) | ₦10,000.00 | |
| Bank Account (1200) | ₦10,000.00 |
This is the BANDAID. The real fix is structural: add an `idempotency_key` column to journal_entry with a UNIQUE INDEX, and store the provider's event_id (e.g. 'dep_abc123') on every insert. The second INSERT fails at the database layer. The bug never happens again. This single line of schema separates 'fintech that works' from 'fintech under regulatory investigation.'
Takeaway
Webhooks retry. Provider events arrive twice. Without idempotency keys, your ledger silently doubles. The fix is one UNIQUE INDEX on a column you must remember to add: `idempotency_key` carrying the provider's event_id. Every fintech that ships without it ships a corrupted ledger.
The code behind it
Reference solution
CREATE TABLE journal_entry (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
description text NOT NULL,
posted_at timestamptz NOT NULL DEFAULT now(),
idempotency_key text
);
-- Partial unique index: enforces uniqueness only for non-NULL keys,
-- so manually-posted entries (key IS NULL) are still permitted.
CREATE UNIQUE INDEX journal_entry_idem_uniq
ON journal_entry (idempotency_key)
WHERE idempotency_key IS NOT NULL;Practice this on a real ledger
Reading is half of it. Open this lesson in the lab to post the entries yourself against a real Postgres-backed double-entry ledger, with the validation on. Free, your sandbox is yours.