Skip to content

Article

PSP Webhook Reliability: Don't Double-Credit a Deposit

A duplicate deposit webhook becomes a wager and a withdrawal inside seconds, which iGaming bonus-abuse rings actively probe. The four-layer handler design generic webhook content misses: HMAC verification, idempotency inside the wallet write, DLQ thresholds, and settlement-file reconciliation.

Editorial Team

Verified May 8, 2026

iGaming Payment Solutions

Deep-diveUpdated

A duplicate deposit webhook lands during a PSP retry storm. The handler's dedup check sits in front of the wallet write, the dedup table is on a read replica that lags the primary, the second handler instance reads "not yet processed", credits the wallet a second time, and the bet engine sees the doubled balance before the dedup row replicates. The player wagers the full amount on a single hand, wins, requests a withdrawal, and the operator absorbs both the duplicate credit and the payout. The exploit is not theoretical. It is the failure mode bonus-abuse rings probe operator endpoints for, and it is the reason iGaming sits at the top of every chargeback league table card networks track, per ChargebackGurus's iGaming chargeback writeup.

Generic engineering content on webhook reliability does not describe this failure mode. The Stripe, Hookdeck, Apidog, and BoldSign guides treat idempotency as a database UPSERT problem and stop there. They are correct for subscription billing and standard e-commerce, where a duplicate credit can be reversed in batch the next morning. They are wrong for iGaming, where the credited balance is spendable inside seconds, the bet engine does not wait for confirmation, and bonus-abuse rings spend their working hours probing for handlers that resolve the duplicate too late. This article is the iGaming-specific webhook handler design: the four-layer defense the wallet ledger needs and that no SERP article on PSP webhook reliability assembles in one place.

The five-second window: how a duplicate webhook becomes a withdrawal

The wallet credit is not an ordinary database write. On a card or APM deposit, the cashier shows the player a "deposit successful" state the moment the PSP returns an authorization. The bet engine reads against the wallet balance immediately. The first slot spin or hand of blackjack lands inside the same session, often inside the first few seconds after the deposit cleared. Withdrawal eligibility on the credited amount is a contract decision (some books require a minimum wager before withdrawal; some pass the deposit through unwagered) but the balance is live for play either way. The end-to-end timing from "duplicate webhook arrived" to "doubled balance bet and withdrawn" is short enough that any dedup pattern relying on eventual consistency loses the race.

This is the structural difference between iGaming and the e-commerce reference architectures the published guides describe. Stripe's own documentation calls out at-least-once delivery and tells the integrator to "log event IDs to prevent reprocessing duplicates", per the Stripe webhooks page. The pattern works for an order-fulfillment workflow where the duplicate "ship the box" event can be caught at the warehouse picking step. It is too loose for a wallet credit that becomes wager-eligible the same second it lands.

Bonus-abuse rings make the iGaming version of the risk worse. Bonus abuse already accounts for around 64 percent of fraud volume in the iGaming sector, ahead of card fraud and identity theft, per EveryMatrix's bonus-abuse cost analysis. The rings are organized, they share intelligence on which operators have weak handlers, and their probing pattern includes deliberately triggering PSP retry storms by holding webhook-acknowledge responses just over the timeout, then watching whether the second arrival of the same event lands a second credit. SEON's guide on bonus abuse detection and Sumsub's promo-abuse playbook both name multi-account, coordinated-betting rings as the pattern, and the technical layer those rings exploit is exactly the webhook handler.

64%

Share of iGaming fraud volume that comes from bonus and promo abuse

Per EveryMatrix's 2025 analysis, bonus abuse outweighs card fraud and identity theft combined as a fraud category in iGaming. The technical entry point for the highest-leverage version of bonus abuse is the duplicate-credit exploit on a non-idempotent deposit webhook handler, where one valid PSP transaction becomes two wallet credits and the second one funds a withdrawal before reconciliation runs.

The cost asymmetry explains why this matters more for iGaming than for any other vertical that takes webhooks. A missed deposit (the webhook never landed, or landed and crashed without writing the wallet) shows up in chat support inside minutes, the player names the deposit reference, and the operator credits manually. A duplicate credit, in contrast, is silent. The player does not file a support ticket. The withdrawal request looks like a standard withdrawal. The shortfall surfaces only on the next reconciliation pass against the PSP settlement file, and on a regulated or book the reconciliation gap reads as either a fund-segregation breach or a deposit-ledger integrity gap, depending on which auditor reads the books. Both are reportable.

HMAC verification, the replay window, and the tolerance most operators leave wide open

The first defense is signature verification at the edge. Every major PSP that issues webhooks signs the payload with HMAC-SHA256 using a shared secret, and includes a timestamp in the signed envelope so a captured payload cannot be replayed forever. Stripe puts the timestamp and signature in a Stripe-Signature header with t= and v1= parts, with a default tolerance of five minutes between the timestamp and the current server time, per the Stripe webhooks documentation. Worldpay supports HMAC-based signature verification or TLS-certificate validation, with SHA-1 support ending March 2026 and SHA-256 the only supported algorithm thereafter, per the Worldpay events documentation. Paysafe and Nuvei both sign their webhook payloads similarly, per Paysafe's webhook configuration guide and Nuvei's DMN documentation. The verification call should use a constant-time comparison and reject anything that fails, per Didit's writeup of webhook HMAC patterns.

The verification code is short. The bug is what most integrators leave out around it.

import crypto from "node:crypto"
 
function verifyStripeSignature(rawBody: string, header: string, secret: string) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.trim().split("="))
  )
  const t = parseInt(parts.t, 10)
  const v1 = parts.v1
 
  // Replay-window check: the timestamp is part of the signed payload,
  // but you still need to enforce the freshness window yourself.
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false
 
  const signed = `${t}.${rawBody}`
  const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex")
 
  // Constant-time comparison: never use ==, ===, or string equality.
  return crypto.timingSafeEqual(Buffer.from(v1, "hex"), Buffer.from(expected, "hex"))
}

Two failure modes show up in production handlers regardless of the PSP.

The first is a tolerance window that drifted wider than the PSP default. Operators who hit clock-skew false positives in early deployment commonly widened the timestamp tolerance from five minutes to thirty or sixty, and never rolled it back after the NTP issue was fixed. A sixty-minute tolerance is a sixty-minute replay window for any attacker who captures one valid signed payload. The fix is to keep the tolerance at five minutes (the PSP default) and fix the clock-drift problem upstream rather than papering it over at the verification layer.

The second is reliance on HMAC alone for replay protection. The HMAC plus timestamp combination prevents an attacker from forging a new payload or shifting the timestamp on an existing one. It does not prevent the same captured-and-still-fresh payload from being replayed within the tolerance window. The defense is a nonce or event-ID dedup table consulted alongside the signature check, where the event ID extracted from the signed payload is recorded and rejected on second sight regardless of signature validity. This nonce table is a different thing from the wallet-write idempotency layer (covered in the next section): the nonce table protects against replay of valid signed payloads, the wallet-write idempotency protects against the PSP sending the same event twice through normal retry. Both layers are needed.

A working handler verifies the signature, checks the timestamp tolerance against the server clock, looks up the event ID in a short-window nonce table (anything beyond the tolerance window can be aged out), and only then enqueues the payload for processing. Anything that fails any of those checks returns a 4xx so the PSP does not retry, with the exception of clock-skew rejections during the timestamp check, which can return 5xx if the operator wants the PSP to retry against a clock that has since corrected.

Idempotency belongs inside the wallet write, not in front of the handler

The pattern most engineering content describes is a processed_webhooks table sitting in front of the handler logic. The handler reads the table, returns 200 if the event ID is present, otherwise processes the event and inserts the row at the end. This pattern is correct for many fintech use cases. It is the wrong pattern for an iGaming wallet credit, and the bug is subtle enough that it ships to production undetected.

The race condition: handler instance A receives the duplicate webhook, reads processed_webhooks and finds the row missing, enters the credit logic, opens a database connection, and is preempted (load shed, GC pause, container restart, network blip). Handler instance B receives the same webhook five hundred milliseconds later through the PSP retry, reads processed_webhooks, also finds the row missing because instance A has not yet inserted it, enters the credit logic, completes the wallet write, inserts its row in processed_webhooks, returns 200. Instance A resumes, completes its wallet write (now duplicate), inserts its row in processed_webhooks (now constraint violation if there is a unique index on event ID, otherwise a second row), and returns 200 or fails. The wallet has been credited twice. The dedup pattern in front of the handler did not prevent it because the check-then-act pair was not atomic across the two instances.

The fix is to put the idempotency key inside the same transaction as the wallet write, with a UNIQUE constraint that the database enforces atomically rather than a separate read-then-write step in application code, per the Hookdeck idempotency guide and the Brandur writeup of Stripe-style idempotency in Postgres.

BEGIN;
 
INSERT INTO deposit_events (
  source_psp,
  psp_reference,
  event_id,
  player_id,
  amount_minor,
  currency,
  occurred_at,
  status
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'received')
ON CONFLICT (source_psp, psp_reference) DO NOTHING
RETURNING id;
 
-- If RETURNING returned a row, this is a fresh event.
-- The application code reads the result and only credits the wallet
-- when a row was returned. No row means duplicate; respond 200 without
-- crediting anything.
 
UPDATE wallets
   SET balance_minor = balance_minor + $5
 WHERE player_id = $4;
 
INSERT INTO ledger_entries (
  player_id, type, amount_minor, currency, deposit_event_id, occurred_at
) VALUES ($4, 'deposit_credit', $5, $6, currval('deposit_events_id_seq'), $7);
 
COMMIT;

Two things make this pattern work where the upstream dedup pattern fails.

The UNIQUE constraint on (source_psp, psp_reference) is enforced atomically by the database. Two concurrent INSERTs for the same (psp, reference) cannot both succeed regardless of which connection started first, regardless of which read replica each handler instance read against, regardless of GC pauses on any application server, per the AppMaster writeup of Postgres advisory-lock alternatives. The ON CONFLICT DO NOTHING clause turns the constraint violation into a no-op rather than an error, and the application reads the RETURNING clause to learn whether it was the winner or the loser of the race.

The wallet write and the ledger entry are inside the same transaction as the idempotency-row INSERT. There is no window during which the dedup state and the wallet state can diverge. If the transaction commits, all three writes happened. If it rolls back (because of a constraint violation, a connection failure, or any error), none of them happened. Stripe's published guidance on idempotency keys, per Stripe's idempotent-requests reference, is structurally the same pattern: the idempotency key and the side effects share an atomic boundary.

The optimistic-locking layer the wallet ledger needs on top of the idempotency layer is a separate concern, per Modern Treasury's writeup of ledger concurrency control. A wallet that is read-modify-written from multiple paths (deposit credit, bet authorization, withdrawal debit) needs version-column or row-lock protection regardless of whether the path is webhook-driven or not. The webhook handler does not get to skip that layer just because the deposit credit is idempotent against duplicate PSP delivery. The two layers cover different problems: idempotency stops one logical event being processed twice, optimistic locking stops two different logical events corrupting each other under concurrent commit. A correct wallet ledger has both.

One footnote on advisory locks. Postgres advisory locks (pg_advisory_xact_lock keyed on a hash of the PSP reference) are sometimes added on top of the UNIQUE constraint as belt-and-braces protection. They are useful as a traffic light during the transaction window but they are not a substitute for the UNIQUE constraint, per the AppMaster writeup of advisory-lock semantics. Use the constraint as the proof, the advisory lock as the optimization that reduces conflict on hot rows. Skipping the constraint and relying on the advisory lock alone leaves the operator exposed to any code path that releases the lock before commit (a connection drop mid-transaction, a non-transactional advisory call) and the bug surfaces only under load.

Dead-letter queue, retry policy, and what gets paged at 03:00

Verifying signatures and writing idempotently does not handle every failure. A handler can verify a payload, dedup it correctly, and still fail to commit because of a database outage, a downstream service timeout, an unknown player ID (the player record was deleted between deposit initiation and webhook delivery), or a malformed payload that passes signature checks but breaks the application schema. The retry policy and the dead-letter queue are what decide whether those failures wake an engineer at 03:00 with actionable context or wake a player at 09:00 with a missing-deposit support ticket.

The retry shape varies by PSP. Stripe retries failed deliveries for up to three days with approximately sixteen attempts on an exponential backoff curve, then disables the endpoint and notifies the integrator, per the Stripe webhooks documentation. Worldpay starts retries at thirty seconds, expands the gap to a maximum of two hours between attempts, and keeps retrying for up to one week before giving up, per the Worldpay events documentation. Adyen, Paysafe, and Nuvei publish their own schedules, per Adyen's webhook documentation and the equivalent Paysafe and Nuvei pages. The integrator's webhook abstraction layer should normalize these into one queue with one retry policy on the operator side, because the production failure handling cannot be conditional on which PSP sent the message.

The decision tree on retries: send to the dead-letter queue without retry for any error that no amount of waiting will fix (HMAC verification failure, malformed payload that fails schema validation, unknown player ID, business-rule rejection like a deposit on a closed account). Retry with exponential backoff for transient errors (database connection failure, downstream service 5xx, rate-limit 429, lock-contention timeout). The classification matters because a retried HMAC failure is wasted load that delays the legitimate traffic, and a non-retried database timeout is a missed deposit, per the Hookdeck DLQ guide and Apidog's payment-webhook best-practices writeup.

The alert thresholds on the DLQ are not optional. The DLQ should fire alerts on three signals, per the Hookdeck guidance: queue depth above a critical-event threshold (Hookdeck names ten events as a starting point for critical flows; iGaming deposit handlers warrant a tighter threshold than that), oldest-event age above a time limit (Hookdeck suggests one hour without review), and inflow-rate spikes that suggest a systemic break rather than an isolated event. DLQ retention should run materially longer than the main queue retention; Hookdeck recommends fourteen days for the DLQ versus four days for the main queue, with longer retention for compliance-relevant events.

The iGaming-specific point most generic DLQ guidance misses: a deposit event in DLQ is a player who paid but is not crediting. The chat support team will get the call inside ten to fifteen minutes on most cases (the player attempted to bet, found their balance unchanged, and went to support). The DLQ alert should reach the on-call engineer before the chat ticket reaches Tier 2 support, because the manual credit (issued to keep the player) on a webhook that later succeeds delivery from the retry queue produces exactly the duplicate-credit scenario the idempotency layer was supposed to prevent. The mitigation is to gate the manual-credit workflow on a "no pending DLQ event for this PSP reference" check, run by support tooling against the DLQ index, before the credit issues.

A worked retry-policy table for an iGaming webhook handler:

Failure classRetry behaviourDLQ behaviour
HMAC signature verification failedNo retry, 401Send to DLQ-security with high alert priority
Timestamp outside tolerance windowNo retry, 400Send to DLQ-security; investigate clock drift or replay attempt
Schema validation failed (malformed payload)No retry, 400Send to DLQ-malformed; PSP integration bug or attacker probe
Unknown player IDNo retry, 422Send to DLQ-unmatched; possible deleted account or wrong endpoint
Database transient error (connection, lock timeout)Retry with exponential backoff, max 7 attemptsSend to DLQ-retryable on attempt 8
Downstream service unreachable (wallet API)Retry with exponential backoff, max 7 attemptsSend to DLQ-retryable on attempt 8
Idempotency conflict (duplicate detected, no credit issued)No retry, 200No DLQ; logged for monitoring as a benign duplicate

Settlement-file reconciliation is what catches the silent misses

Webhooks are advisory, not authoritative. The PSP settlement file (Stripe Reports, Worldpay reconciliation export, Adyen settlement detail report, Nuvei daily reconciliation file) is the source of truth for what the PSP actually settled to the operator's bank account. The reconciliation pass that matches the settlement file against the operator's deposit_events table is the safety net that catches what the webhook layer missed, per Stripe's reporting and reconciliation documentation and the equivalent on the other PSPs.

Two failure modes surface only on reconciliation, both of them invisible to the webhook handler.

Missed deposits are settled transactions in the PSP file with no matching deposit_events row. The webhook delivery failed beyond the PSP retry envelope, the handler failure put the event in DLQ and nobody replayed it, the network split between PSP and operator dropped the delivery during a regional outage, or a config error pointed the webhook at the wrong endpoint for a window. The remedy is to credit the player the same way the webhook handler would have, then alert the engineer who owns the webhook integration with the gap reason. The credit goes through the same idempotency layer, keyed on the PSP reference, so a webhook that arrives later from manual replay does not double-credit.

Phantom credits are deposit_events rows with no matching settlement-file line. Two reasons: the webhook arrived but the PSP later refunded or reversed the underlying transaction (and the reversal webhook either failed to deliver or has not arrived yet), or the webhook was forged and the signature-verification layer let it through (which means the verification layer is broken and the entire handler is compromised, not a routine reconciliation issue). The reconciliation pass routes phantom credits to ops review rather than auto-fixing, because the response depends on which class the row falls into and the wrong response on a forged credit is to silently reverse it, which destroys the audit trail an investigation would need.

The reconciliation pass needs to run with awareness of the settlement-timing window. A transaction authorized on Day 1 may not appear in settlement until Day 3. A reconciliation pass that compares yesterday's webhooks against yesterday's settlement file will produce false-positive missed-deposit alerts for transactions that are still in the PSP's settlement pipeline. The matching engine should look across a configurable date range (typically Day-3 to Day+1 against the run date) rather than a single day, per the standard guidance on settlement-file reconciliation.

The output of the reconciliation pass that matters most for an iGaming book is the reconciliation status of every deposit_events row by end of Day+3, with three states: reconciled (matched in settlement), pending (still inside the settlement window), gap (settlement window passed without a match). Gap rows are the audit problem. Under LCCP fund-segregation reporting and Player Protection markers-of-harm analysis, the operator needs to be able to demonstrate every credited deposit was settled and every settled deposit was credited; a persistent gap on either side of the reconciliation is what gets cited in an audit finding.

Finally, the reconciliation pass is the only layer that catches a webhook handler outage that exceeds the PSP retry envelope. Stripe gives up after three days, Worldpay after one week. An operator-side outage that runs longer than the PSP retry envelope (a misconfigured WAF rule that drops webhook traffic, a DNS failover that points the endpoint at a dead server) loses webhooks permanently. The settlement-file reconciliation is what surfaces those losses on the next run. Operators that skip reconciliation entirely (or run it weekly instead of daily) discover those gaps from a regulator or an auditor, which is materially worse than discovering them from an internal pipeline.

What each PSP gives you out of the box, and what you build on top

Every PSP that issues casino webhooks ships a different shape, and the operator's webhook abstraction layer has to normalize them into a single internal event format before the handler logic runs. The shape differences are the reason a handler tested against one PSP fails open against another after a migration.

The shape table for the major casino-acquirer webhooks:

PSPSignature schemeRetry envelopeEvent-history API
WorldpayHMAC-SHA256 (or TLS cert chain)30s start, max 2h gap, 1 week totalLimited; via Implementation Manager
Nuvei (DMN)HMAC, signed timestampConfigurable, multi-dayControl Panel event history
PaysafeHMAC-SHA256Multi-day exponentialAPI event log
AdyenHMAC-SHA256 with shared secretMulti-day exponentialCustomer Area event browser
Stripe (where used in iGaming-adjacent rails)HMAC-SHA256, t/v1 envelope3 days, ~16 attemptsWorkbench event destinations

The differences that bite in production are not the algorithms (every PSP signs HMAC-SHA256 in 2026, after the SHA-1 sunset) but the auxiliary surface. Stripe's event-history API and Workbench, per the Stripe undelivered-events documentation, let an integrator query for a specific event ID and replay it without a custom integration. Worldpay does not publish an equivalent and an operator who needs to replay a missed event has to open a ticket through the Implementation Manager. Adyen exposes the event browser inside Customer Area, which works for spot-check but does not script. Nuvei's Control Panel exposes DMN event history but not at the same depth as Stripe's events list. The operator who builds the webhook abstraction layer with these differences in mind has a working replay path on Day 1 of an outage; the operator who builds against Stripe-shape only and discovers the gap during a Worldpay outage spends the outage assembling a manual replay path under load.

The abstraction layer the handler should sit behind has four contracts: a normalized event format that maps every PSP's payload into one schema, a normalized retry policy that applies the operator's classification table (covered earlier) regardless of the PSP's own retry curve, a normalized DLQ with PSP-tagged buckets so the alert routing knows which integration is misbehaving, and a normalized replay path that pulls from PSP event-history APIs where available and from the operator-side captured-payload archive where they are not. The captured-payload archive is a fifth piece worth flagging: storing the raw signed payload of every received webhook (after signature check) for fourteen to thirty days lets the operator reconstruct events even when the PSP's event-history API does not return what the handler needs.

The cost asymmetry sits at the bottom of every decision in this article. A missed deposit is a chat ticket and a manual credit (cost: a few minutes of support time). A duplicate credit is a withdrawal that funds a bonus-abuse ring and an audit finding (cost: the credit, the payout, a chargeback margin allocation, and on a or book a key-event filing if the gap reads as a fund-segregation issue). The four-layer defense (HMAC verification with a tight replay window, idempotency inside the wallet write transaction, classified retries with a DLQ and tight alerts, daily settlement-file reconciliation) is what closes the gap that generic engineering content leaves open. None of the four layers is sufficient on its own. All four together are the iGaming-specific webhook handler that the published SERP guides do not assemble.

Sources (20)

  1. 01Stripe: Receive Stripe events in your webhook endpoint
  2. 02Stripe: Process undelivered webhook events
  3. 03Stripe API Reference: Idempotent requests
  4. 04Worldpay: Events Webhook documentation
  5. 05Worldpay Developer Hub: Event Notifications configuration
  6. 06Paysafe Developer: Configure Webhooks
  7. 07Nuvei: Direct Merchant Notification (DMN) webhooks
  8. 08Adyen: Webhooks documentation
  9. 09Hookdeck: Dead-Letter Queues for Webhook Reliability
  10. 10Hookdeck: How to Implement Webhook Idempotency
  11. 11Brandur: Implementing Stripe-like Idempotency Keys in Postgres
  12. 12Modern Treasury Journal: Designing the Ledgers API with Concurrency Control
  13. 13AppMaster: PostgreSQL advisory locks for concurrency-safe workflows
  14. 14EveryMatrix: Bonus abuse in iGaming, the hidden costs operators ignore
  15. 15Sumsub: Bonus Abuse in Gambling, Types, Risks and How to Prevent It
  16. 16SEON: Guide to Bonus Abuse in iGaming, How to Detect and Prevent
  17. 17ChargebackGurus: Preventing iGaming Chargebacks
  18. 18Didit: Webhook Security, HMAC, Retries, Idempotency
  19. 19Apidog: Payment Webhook Best Practices
  20. 20BoldSign: Webhook Best Practices, Idempotency and Event Ordering