The starting point for the decision
The payment provider shows an approved transaction, but the customer’s order list is empty. An operator processes the order again, and two access passes are issued. This is a design example, but it illustrates what can happen when the whole process is rerun without knowing which steps succeeded.
The required solution is not another payment button. It is a structure that separately records what the order promised, how far payment progressed, and whether goods or access were actually delivered, then performs only missing steps based on verified facts. Do not assume an external payment and your database can commit together; design recovery to find work interrupted between them.[9]
If a payment outcome cannot be confirmed, leave it unresolved, not failed. If payment succeeded and only fulfillment failed, do not charge again. If only the fulfillment response was lost, do not issue again until actual fulfillment is checked. These distinctions are the foundation of safe reprocessing.
A single success state is insufficient
This article primarily uses the Toss Payments flow where the server approves payment after authentication in the payment window. The internal model is a design example based on PostgreSQL 17, not a specific merchant implementation or real incident. Recurring-charge policy, partial-refund calculations, and reconciliation of settlement/bank deposits remain separate concerns.
Let orders own purchase details and fulfillment policy, payment records own transaction facts confirmed by the provider, and fulfillment records own actual work such as stock deductions, pass issuance, and delivery requests. The three states must connect, but should not overwrite one another with the same value.
| Situation | Example internal order state | Toss payment state or confirmation | Example actual fulfillment state | Next decision |
|---|---|---|---|---|
| After payment-window authentication, before server approval | Awaiting payment | IN_PROGRESS | Not started | Validate the order, then proceed with approval. |
| Virtual account issued, before deposit | Awaiting deposit | WAITING_FOR_DEPOSIT | Not started | Do not treat account issuance as received payment. |
| Approval and internal application complete | Order confirmed | DONE | Awaiting fulfillment | Run fulfillment separately. |
| Approved, but order DB update failed | Recovery required | DONE confirmed by lookup | Not started | Recover internal application without charging again. |
| External pass-issuance response lost | Order confirmed | Payment verified | Outcome unresolved | Query issuance before deciding the next action. |
| Order and fulfillment complete | Fulfilled | Payment verified | Fulfillment complete | Preserve completion evidence; handle later corrections separately. |
| Payment cancellation verified | Full/partial cancellation under review or applied | CANCELED / PARTIAL_CANCELED | Review stopping/revoking the affected scope | Distinguish the canceled scope from what has already been delivered. |
The internal state names above are proposals, not gateway values. Toss DONE means approval complete, but card-acquisition status is a separate field and settlement information is also distinct. It does not immediately mean money has reached the merchant’s bank account.[2]
Assigning numbers to states and accepting only higher values is unsafe too. Current Toss webhook documentation describes virtual-account deposit errors where DONE can become WAITING_FOR_DEPOSIT; it separately describes behavior for versions 1.4 and earlier. Preventing incorrect reversal caused by an old notification differs from applying an actual provider correction.[4]
An order and a payment attempt are not the same identifier
Define the following relationship first.
Internal order → payment attempt → gateway order/payment identifiers → fulfillment work per purchased item
Store products, quantities, the server-calculated expected amount, currency, buyer, and applicable policy on the internal order. On each payment attempt, record the gateway, merchant account, test/live environment, and request sent. Link Toss orderId and paymentKey to that attempt.[1][2]
Design example: if a genuinely new payment attempt is allowed for an internal order, create a separate attempt record and associate a gateway order identifier. Retrying the same operation after losing a response is not a new payment attempt; preserve its identifiers and work record. Separately constrain whether a new payment is permitted while an earlier attempt remains unresolved.
Fulfillment deduplication, however, is based on the purchased item or entitlement, not the payment attempt. Two successful payments for one order must not be covered up by issuing two passes. Preserve both payment facts and flag the additional collection for separate review.
Connect transaction states from order to refund — Integrate payment methods with orders and verify delays, retries, cancellations and refunds.
The server confirms the order, not the success page
In this Toss flow, returning to the success URL is the result of buyer authentication; server-side approval is separate. The official guide instructs saving the order number and amount before requesting payment, then comparing returned values before approval.[1]
Do not stop at “the server stored the client’s amount, so it is safe.” The following is a server-validation design extending that guidance.
Before approval, verify authorization to access the order, whether it remains payable, and whether the requested amount matches the server calculation from prices, discounts, and quantities. Bind the gateway, merchant account, environment, and currency to the order’s expected values. A browser-supplied order number must not allow the processing target to be switched.
Call POST /v1/payments/confirm only for a validated attempt, and compare returned payment ID, order number, amount, and state with expectations. If the result is lost, use the official lookup path with paymentKey or orderId.[2]
If the approval response represents virtual-account issuance, do not immediately branch into fulfillment. Have the interface read server-side order, payment, and fulfillment state to distinguish “awaiting deposit,” “checking payment,” and “paid, preparing goods.” Actual processing results must survive a closed browser or a reopened success page.
One idempotency key does not prevent every duplicate
Idempotency prevents repeated requests for the same logical operation from creating duplicate results. What matters is which operation, in which system, counts as the same one. Preventing duplicate approval at the provider and preventing duplicate pass issuance in your system are separate responsibilities.
Keep the same operation key for the payment API
Toss distinguishes requests using the combination of Idempotency-Key, API key, API URL, and HTTP method. Its documentation gives a 15-day validity period from first use and says the first request’s response is returned again. It warns against changing the key and repeating the same request merely because an error occurred.[3]
Accordingly, persist the key and request contents when creating the approval operation. Internally reject reuse of that key if amount, target, or operation type changes. Retries of the same operation use the stored contents unchanged.
Do not execute an unresolved payment as a new operation merely because the key expired or the authentication API key was rotated. Check the existing transaction first. Also, receiving the first response again differs from querying current payment state. If cancellation may have happened after approval, do not reconfirm the order solely from a replayed old approval response. This design precaution follows from the documented key scope and response-reuse behavior.[3]
Add separate uniqueness constraints in your database
The following design example divides deduplication responsibilities.
| Protected object | Example definition of the same operation | What this does not protect |
|---|---|---|
| Gateway approval/cancellation request | Logical operation ID, request contents, provider-defined idempotency scope | Internal DB application or goods issuance |
| Internal payment record | Gateway + merchant account + environment + gateway payment ID | Later cancellation or correction events for that payment |
| Webhook receipt record | Provider-guaranteed event identifier and scope | The same business effect represented by another event ID |
| Internal item/entitlement fulfillment | Order-item unit + fulfillment action + necessary fulfillment version | Work already performed in an external issuer |
| External issuance request | Fixed operation identifier/idempotency key supported by the issuer | Work outside the issuer’s deduplication scope |
This example assumes one fulfillment per purchased item unit. If customers buy several units or shipments are split, define the fulfillment units first. Legitimate additional purchases must not be discarded as duplicates.
PostgreSQL uniqueness constraints can prevent duplicate column combinations. Under default behavior, however, NULL values are not considered equal, so an empty deduplication key is not automatically safe. Distinguish attempts whose key is not yet available from payment records with confirmed identifiers, and apply NOT NULL where required.[8]
An application-level “check whether it exists, then create it” is insufficient for concurrent requests. Combine uniqueness constraints with state-transition conditions, and have conflicting requests reread the existing result. Distinguish the payment ID from operation/event IDs so that having seen a payment does not cause its later cancellation to be ignored.
Commit recorded success and subsequent work together
A local database transaction commits or cancels changes within its boundary. It does not automatically reverse an external gateway approval.[7][9]
The following design example separates local transactions from external calls.
- Before the call: persist the order, payment attempt, approval request contents, and idempotency key. Avoid holding an order-row database lock throughout the external payment call.
- After the gateway call: open a short local transaction based on verified transaction facts. Commit payment-result application, permitted order changes, fulfillment-job registration, and events to publish together.
- After commit: a separate worker delivers events. The receiver applies its processing record and business change together and returns the existing result for already-processed work.
An outbox is a record of events to deliver, stored with the business change. It reduces the dual-write gap where payment application commits but work registration is omitted. An inbox records message receipt and processing results; durable acceptance and completed business processing are distinct. AWS’s transactional outbox explanation and Particular’s NServiceBus implementation documentation likewise describe combining local changes with message records and separately handling duplicates.[9][10]
If a worker stops immediately after sending an event, it may fail to record the send, causing redelivery. An outbox therefore does not justify claiming “exactly-once delivery.” Design the receiver so repeated delivery does not repeat the business effect.[9][10]
Apply the same rules to concurrent callbacks and stale lookup results
Browser follow-up requests, webhook handlers, periodic recovery, and operator reprocessing can overwrite one another if each modifies order state directly. This article proposes routing them through one set of state-transition rules and deduplication controls.
For example, compare the internal state version read when a lookup starts with the version at application time. If cancellation or another change occurred in between, reassess instead of applying the stale observation unchanged. Do not decide the latest transaction solely from event arrival time or assume provider transaction identifiers are sortable version numbers.
Preventing internal contention does not lock the gateway and external issuer simultaneously. Cancellation or deposit correction can still occur immediately after the last payment check. Define checks before irreversible fulfillment, coordination between your cancellation requests and fulfillment jobs, and stopping/revocation/compensation after later corrections. Compensation here is a policy-driven subsequent action, not a database rollback that rewinds the original payment.
A lost external issuance response is not solved by running it again
When an access pass is a row in your own database, deduplication and entitlement creation can share a transaction. Stock deductions in the same database can also fit the chosen boundary. An external API issuing a pass or accepting a shipment creates a new failure boundary.
Design example: the issuer creates the pass, but the connection breaks while returning the response. Your worker sees only a timeout. An internal “processing” record proves neither success nor failure at the issuer. Expiry of a worker lease or lock does not by itself authorize another issuance.
If supported, query using the same operation identifier or repeat the identical request with the same idempotency key. If neither is available and no other evidence establishes issuance, leave fulfillment outcome unresolved for human review. This is a point where automatic reissuance should stop. Particular’s documentation also explains that outbox guarantees do not extend to external side effects outside its transaction.[10]
Accordingly, separate recovery actions into “query payment,” “repair internal application,” “query fulfillment,” and “rerun verified unfulfilled work,” instead of one “rerun the entire order” button.
Verify webhook intake and tolerate duplicate processing
The important questions are who sent the webhook, which transaction it refers to, and whether its result may be applied now. Authentication of an old notification does not authorize overwriting current state.
Toss documents comparing DEPOSIT_CALLBACK with the approval response’s secret. Its core API also describes secret comparison for the Payment object in payment-state webhooks and defines the field as nullable. Compare a nonempty secret obtained through a trusted path; do not treat null == null as successful authentication. This value also differs from the secret key used to authenticate API requests.[2][4]
The documentation limits tosspayments-webhook-signature to payout.changed and seller.changed. Requiring it unchanged for general payment-state webhooks is incorrect.[4]
A safe design alternative when a supported authentication method or comparison value is unavailable is to treat the notification only as a signal to re-query, not as evidence authorizing state change. Limit queries to known orders, merchant accounts, and environments, and perform business work only from provider-confirmed results. Do not deliver goods based solely on amount or state in the webhook body. Apply request-size limits, valid transaction-ID checks, and query-rate limits to this intake path as well.
Toss’s webhook guide requires HTTP 200 within 10 seconds and describes up to seven redeliveries after failure. Rather than completing slow fulfillment inside that request, perform the available verification and respond after durably recording an intake entry that supports later processing. Do not acknowledge success when storage fails. Distinguish invalidly authenticated requests from normal intake.[5]
For comparison, Stripe separately documents automatic live-mode redelivery, lack of event-order guarantees, and duplicate-event handling. Apply its official signature-verification method using the raw request body, Stripe-Signature, and the endpoint secret. Do not transfer those conditions to Toss or another gateway as though they were shared guarantees.[6]
Use received event IDs for traceability and duplicate detection, but verify their scope and persistence across retries in the provider contract. This article does not assume Toss transmission identifiers remain the same business-event ID on every retry. Even if some duplicate deliveries are not detected, the final fulfillment constraint must prevent duplicate effects.
Intake records should retain transaction-linking identifiers, receipt time, verification outcome, and processing state without copying secrets, card data, or raw customer information into ordinary logs. Separating the provider observation time from internal application time also helps trace delays and reprocessing.
From transaction alerts to response and provider review — Follow symptom-based checks to establish transaction status and handle incidents, refunds and recurring issues.
Change the recovery action according to the failure point
All cases below are design examples. They apply state separation, idempotency, and local transactions to failure points; they are not a list of automatic recovery features or guarantees from a particular gateway.
| Failure point | Difference to detect | Safe next action | Deduplication or stopping condition |
|---|---|---|---|
| Order DB commit fails after approval | Approval exists; order application and fulfillment job do not. | Query the same transaction and recover internal application. | Do not create a new approval request. |
| Approval response is lost | Call exists; outcome is unresolved. | Query the gateway or use a permitted retry of the existing idempotent operation. | Do not charge with a new order or key. |
| Callback and webhook arrive together | Several application attempts for one order/transaction | Serialize through the common transition path; reread after conflict. | Payment uniqueness plus item-level fulfillment constraints |
| Old approval notification arrives after cancellation | Observation order conflicts with internal history. | Check the current transaction and apply only permitted transitions. | Do not resume fulfillment from an old success response. |
| Worker stops just after outbox publication | Message delivery is uncertain. | Allow the same event to be redelivered. | Receiver processing records and business constraints |
| External issuance response is lost | Issuance request exists; outcome evidence does not. | Query the same operation’s issuance result. | Stop automatic reissuance if unverifiable. |
| Deposit is confirmed after order expiry | Payment fact conflicts with fulfillment eligibility. | Preserve the deposit fact and review stock, expiry, and cancellation policy. | Do not delete payment records or fulfill unconditionally. |
| Virtual-account deposit is corrected | Earlier confirmation conflicts with the new provider result. | Apply current state, stop pending fulfillment, and review delivered items. | Do not continue fulfillment solely from past DONE. |
| Different attempts for one order are both approved | Multiple transactions correspond to one purchase obligation. | Record both and review excess payment and fulfillment scope. | Do not issue the same item once per payment attempt. |
Recovery that finds unresolved cases must work in both directions
Recovery starting only from internal orders may miss payments whose order association itself was lost. This article proposes both directions below.
Check from the internal system toward the gateway. Find unresolved approvals, verified payments not applied internally, prolonged fulfillment waits, and unresolved external fulfillment. Record the last check time and responsible work, avoiding conflicts with processing already underway.
Check from the gateway toward the internal system too. Use official approval/cancellation history sources, such as Toss core API transaction lookup, to find transactions without matching internal records. Reconciliation here checks payment/order/fulfillment links, not settlement fees or bank-deposit amounts.[2]
Persist query windows and pagination checkpoints, and tolerate overlapping collection windows without duplicate application. Do not attach an unlinked transaction to an order merely because amounts match. A failed lookup or empty result alone does not establish that money was not collected; first verify the query target, merchant account, environment, and response error.
Do not classify retries by HTTP status alone
Toss error documentation lists both ALREADY_PROCESSED_PAYMENT and PROVIDER_ERROR, which describes a temporary problem, under HTTP 400. Rather than a universal “all 4xx errors are permanent” rule, use operation-specific and error-code-specific handling.[11]
For an already-processed payment, query the actual transaction. For input or permission issues, correct the cause before deciding. Apply bounded retries to transient failures while preserving the existing operation’s identity. Toss IDEMPOTENT_REQUEST_PROCESSING means the previous idempotent request is still processing; it is not grounds to create a new payment immediately.[3]
Retry design can bound both count and total elapsed time and use increasing intervals with jitter. Choose limits according to payment method, fulfillment deadline, gateway policy, and operational capacity. Do not make example numbers universal defaults.
Amount/currency/order-link mismatches, failed authentication, a changed idempotency scope while unresolved, unverifiable external fulfillment, and repeated failures are conditions for manual review. Record an owner, next check time, and actions currently prohibited. Manual state changes must also record reason, evidence, actor, before/after state, and operation ID, and pass through the same deduplication path.
Test failure boundaries, not just normal payments
One successful screen cannot validate this design. The following are test-design proposals for an owned or authorized test environment.
Fail the internal commit immediately after approval and verify that only the order is repaired, without another payment. Process callbacks and webhooks concurrently and deliver the same event repeatedly, checking that item-level fulfillment does not multiply. Verify that a failed internal transaction does not leave only the inbox marked complete or lose only the outbox entry.
Test old approval notifications after cancellation, deposits after order expiry, virtual-account deposit corrections, and lost responses after successful external issuance. Particularly verify that when issuance cannot be checked, work stops as unresolved/awaiting review instead of automatically issuing again.
Retention periods also require testing. An old notification received after event-deduplication records were deleted must not create duplicate fulfillment. Include API credential rotation while an approval response remains lost as a separate scenario. Particular’s outbox documentation likewise recommends considering delay and retry possibilities when setting deduplication-record retention.[10]
An operations screen should show paid-but-unfulfilled counts, the age of the oldest unresolved case, unlinked gateway transactions, repeatedly failing work, and owners. The objective is to reduce unresolved customer outcomes, rather than merely count failures.
A small system can start small
This structure does not require starting with a message broker or microservices. A service with one database and worker can begin with durable payment attempts and processing records, business-appropriate uniqueness constraints, transactionally registered follow-up work, and periodic checks. The key is retaining enough information and ownership to continue after failure, not the number of components.
Conversely, multiple gateways, order systems, and issuers, or many unverifiable external effects and manual state changes, make isolated code fixes insufficient. First establish which system is authoritative for each fact and who decides exceptions.
Reprocessing does not mean starting over. It preserves verified payment facts and continues only the next work confirmed not to have been performed. Start by choosing one order and checking whether its three states, linking keys, and latest execution result can be explained.
If you need outside review of current payment/order flows and failure scenarios, see the support scope available through IXC’s payment-gateway integration and payment-system implementation service.



