Use a stable external payment identifier as a unique key, then create the ledger credit and update the wallet balance in one database transaction. If that key already exists, confirm that the original credit committed and return a successful response without creating another credit. This makes the wallet effect idempotent even when a provider retries, duplicates, delays, or concurrently delivers a webhook.
The Short Answer: Make the Wallet Credit Idempotent
A payment webhook should be treated as a notification that may arrive more than once—not as permission to add funds every time the endpoint is called. The system should enforce a simple accounting invariant:
> One externally confirmed payment can create at most one wallet credit for the intended business event.
The strongest place to enforce that invariant is durable storage. Assign the credit a documented external identity, protect that identity with a database unique constraint or equivalent transactional mechanism, and commit the corresponding ledger entry and wallet mutation atomically.
A typical flow is:
- Verify the webhook signature using the payment provider's current procedure.
- Confirm that the event type and payment state are eligible to create a credit.
- Derive a stable deduplication key for the specific financial effect.
- Begin a database transaction.
- Insert an append-only ledger credit containing that unique key.
- Update the wallet balance in the same transaction, if a stored balance is maintained.
- Commit both writes together.
- Return a successful response only after the required durable state has been committed.
If the unique ledger key already exists, the handler should read or confirm the existing committed result. It can then acknowledge the duplicate without changing the ledger or balance again.
Why webhook delivery should be treated as at least once
Webhook senders commonly retry when delivery times out, the receiving service returns an error, or the sender cannot determine whether a response arrived. Network interruptions can also create an ambiguous outcome: the receiver may have committed the wallet credit even though the provider never received its successful response.
Duplicates can therefore arrive:
- Seconds or minutes after the original delivery
- After a timeout or temporary server error
- At the same time on different application instances
- Much later as a delayed retry or replay
- In a different order from related payment events
Trying to guarantee exactly-once delivery across these boundaries is not the practical objective. The objective is an idempotent local effect: repeated delivery of the same qualifying payment does not create another financial movement.
The invariant: one external payment can create only one wallet credit
The invariant should be represented in the data model rather than left entirely to application code. For example, a ledger table might have a unique constraint covering the provider account, external payment identity, and financial movement type:
``sql UNIQUE (provider_account_id, external_payment_id, movement_type) ``
Including the provider account avoids accidental collisions across accounts. Including the movement type distinguishes a payment credit from a later refund, reversal, or adjustment. The exact key depends on the provider's event model and the wallet's accounting rules.
An event ID can be appropriate when a provider guarantees that retries preserve the same event ID and each event maps to one business effect. A payment or transaction ID may be safer when several separately identified events can describe the same successful payment. Do not assume either field has the required semantics without checking current provider documentation.
The ledger entry should retain enough information to audit the decision, such as the wallet ID, amount, currency, external payment identity, event identity, movement type, processing timestamp, and relevant payment state. A mutable wallet balance alone cannot show why funds changed or prevent a second insert reliably.
Verify the Webhook and Establish a Stable Payment Identity
Authenticity and idempotency solve different problems. Signature verification helps determine whether a request was generated by the expected provider and whether the signed content is valid. Deduplication determines whether the financial effect has already been applied. A validly signed webhook can still be a duplicate.
Validate the provider signature before processing
Verify each request using the selected provider's documented procedure before accepting it for business processing. Depending on the provider, verification may require the unmodified request body, one or more headers, a configured secret or public key, and a timestamp check.
The endpoint should avoid parsing and reserializing the payload before verification if the provider signs the raw request bytes. It should also apply any documented replay-window or timestamp rules. Invalid signatures should not reach the wallet-credit transaction.
Signature algorithms, payload construction, secret rotation, timestamp tolerance, and response expectations vary. Implement these details from the provider's current documentation rather than copying assumptions from another integration.
Choose a documented event ID, transaction ID, or composite key
The deduplication key must identify the business effect that should occur once. Possible inputs include:
- A provider event ID that remains unchanged across retries
- A payment, charge, transfer, or transaction ID
- The provider account or merchant identity
- The movement type, such as
payment_credit - A carefully defined composite key when no single field is sufficient
Avoid constructing the key from unstable values such as delivery timestamps, request IDs that change on each retry, or the entire serialized payload. Also avoid relying only on wallet ID and amount: two legitimate payments can have the same amount and destination.
Before choosing a key, answer two questions:
- Can the provider send the same business event under more than one event ID?
- Can one external transaction legitimately create more than one distinct ledger movement?
The first question determines whether an event ID alone is sufficient. The second prevents an overly broad constraint from blocking legitimate refunds, reversals, partial captures, or adjustments.
Do not confuse outbound API idempotency with inbound event deduplication
An idempotency key attached to an outbound payment API request protects that request according to the provider's rules. It does not automatically deduplicate webhooks later sent to your system.
Inbound processing needs its own durable identity derived from documented webhook and transaction fields. The outbound key can sometimes be stored as useful correlation data, but it should not be treated as the inbound uniqueness rule unless the provider explicitly defines that relationship.
Enforce Deduplication and the Wallet Mutation Atomically
A vulnerable implementation first queries, “Has this event been processed?” and then inserts a credit in a separate operation. Two workers can run that query simultaneously, both receive “no,” and both add funds. This is a classic check-then-write race.
An in-memory flag has the same weakness and introduces additional problems. It can be lost during a restart, cannot coordinate independent service instances reliably, and may expire before a delayed retry arrives.
Use the database as the final concurrency authority. The transaction should couple the unique ledger insertion with any balance update:
```text handleWebhook(request): if not verifyProviderSignature(request): return HTTP 401 or provider-appropriate rejection
event = parseAndValidate(request)
if not isSupportedEventType(event): return provider-appropriate successful acknowledgment
if not isEligibleForWalletCredit(event): return provider-appropriate successful acknowledgment
key = deriveFinancialEffectKey(event)
try: begin transaction
ledgerEntry = insert ledger_credit( unique_external_key = key, wallet_id = resolveWallet(event), amount = validatedAmount(event), currency = validatedCurrency(event), provider_event_id = event.id, external_payment_id = event.payment_id )
update wallet_balance set balance = balance + ledgerEntry.amount where wallet_id = ledgerEntry.wallet_id
commit transaction return HTTP success
catch unique_constraint_violation: rollback transaction
existing = findCommittedLedgerEntry(key) if existing matches expected wallet, amount, currency, and type: return HTTP success
raise conflict for investigation
catch transient_processing_error: rollback transaction return provider-appropriate retryable failure ```
The unique constraint handles sequential and concurrent duplicates. Only one transaction can insert the protected key. Other attempts encounter the uniqueness conflict and take the duplicate branch without applying another credit.
The duplicate branch should also validate important fields against the existing ledger record. If the same key arrives with a different wallet, currency, amount, or movement type, silently treating it as an ordinary duplicate could hide data corruption or an integration defect. Quarantine or escalate that conflict instead of adding funds.
Record Completion Without Creating a Failure Gap
A processed-events table can be useful, but it must not become disconnected from the financial mutation. Consider this unsafe sequence:
- Mark event as processed.
- Attempt to add the wallet credit.
- Credit fails.
If step one commits independently, a retry may see the processed marker and skip the credit permanently. Reversing the sequence is also unsafe when the credit commits but the processed marker does not.
For synchronous handling, insert the ledger entry, update the balance, and mark any processing state complete within one transaction. If any required operation fails, roll back the entire transaction.
For asynchronous handling, distinguish durable receipt from completed processing:
- Verify the incoming request.
- Insert it into a durable inbox with a unique provider event identity.
- Commit the inbox record.
- Acknowledge receipt if that behavior matches the provider contract.
- Have a worker process the inbox record.
- In a separate atomic transaction, insert the uniquely keyed ledger credit and update the wallet balance.
A queue or inbox reduces pressure on the public endpoint, but it does not replace ledger-level idempotency. Queues can redeliver messages, workers can crash, and visibility timeouts can cause concurrent processing. The worker must remain safe when the same item is executed repeatedly.
Handle Retries, Crashes, and Ambiguous Outcomes
Failure handling should preserve one of two durable outcomes: either the complete wallet credit is committed, or none of its required writes are committed.
Important failure scenarios include:
- Crash before commit: The transaction rolls back or remains uncommitted. A later retry can try again.
- Crash after commit but before HTTP response: The provider may retry. The unique key detects the committed credit, and the duplicate receives a successful response.
- Balance update fails after ledger insertion: Both writes should roll back. Do not leave a committed ledger credit that the wallet balance does not reflect unless the architecture intentionally derives balances from ledger entries and handles projection state separately.
- Database timeout with an unknown result: Query by the unique key before deciding whether to retry the mutation. Never assume that a timeout means the commit failed.
- Temporary dependency failure: Roll back incomplete work and return the retryable response expected by the provider, or retain the event in a durable queue for another attempt.
HTTP acknowledgment is a delivery signal, not an accounting control. A successful response should mean that the system has reached the durable state required by its design—either the financial effect has committed, the duplicate has been confirmed as already committed, or an asynchronous inbox has safely accepted responsibility for later processing.
Provider retry schedules, timeout behavior, accepted response codes, and maximum delivery windows differ. Confirm them against the selected provider's current documentation.
Use Reconciliation as a Backstop
Atomic idempotency prevents a major class of duplicate-credit failures, but operational reconciliation is still necessary. Reconciliation can compare:
- Successful or settled provider transactions
- Webhook inbox or receipt records
- Wallet ledger entries
- Stored or derived wallet balances
- Refund, reversal, and adjustment movements
Useful exception categories include a successful provider payment with no ledger credit, a ledger credit without a matching provider transaction, a field mismatch for an existing deduplication key, and a wallet balance that does not equal the sum of its ledger movements.
Reconciliation should detect and route discrepancies for controlled resolution. It is not a substitute for atomic processing: correcting preventable duplicates after the fact is more difficult than enforcing uniqueness when the credit is created.
Implementation Checklist and Test Plan
Before release, confirm that the implementation has:
- Provider-specific signature verification before business processing
- Explicit eligibility rules for event type and payment state
- A stable financial-effect key with documented semantics
- A durable unique constraint covering that key
- An append-only or auditable ledger record linked to the external payment
- One transaction for the ledger credit and corresponding balance mutation
- A duplicate branch that confirms the existing committed entry
- Conflict handling when a repeated key carries different financial fields
- Durable queue or inbox storage if processing is asynchronous
- Monitoring for retry exhaustion, processing conflicts, and reconciliation exceptions
Test the design with more than a simple repeated HTTP request:
- Sequential duplicate: Send the identical event twice. Expect one ledger credit and one balance increase.
- Concurrent duplicate: Send the same event simultaneously to multiple service instances. Expect the database uniqueness rule to allow only one credit.
- Crash before commit: Terminate the worker during the transaction. Expect no partial financial mutation and successful processing on retry.
- Crash after commit: Commit the credit, interrupt the response, and deliver the event again. Expect the retry to confirm the existing credit.
- Invalid signature: Send a valid-looking payload with an invalid signature. Expect rejection before ledger processing.
- Old replay: Replay a previously valid event according to the provider's timestamp and signature model. Expect the documented replay controls and durable deduplication rule to apply.
- Changed duplicate: Reuse the external key with a different amount, currency, or wallet. Expect a conflict rather than a second credit.
- Out-of-order events: Deliver related status events in an unexpected order. Expect only the qualifying financial state to create the intended movement.
- Transient database failure: Force a timeout or rollback and verify that a retry cannot create two credits.
- Reconciliation exception: Remove or alter a test record and verify that the discrepancy is detected and routed for investigation.
The implementation is ready only when its correctness comes from durable uniqueness and transaction boundaries—not timing assumptions, one application instance, or the hope that the provider will send each webhook once.
Next Step
This guide covers a general payment architecture pattern and is separate from Token Forge Cloud's AI model-serving products. For API access, private deployment, or LLM inference cost control, contact Token Forge Cloud.