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.

SituationExample internal order stateToss payment state or confirmationExample actual fulfillment stateNext decision
After payment-window authentication, before server approvalAwaiting paymentIN_PROGRESSNot startedValidate the order, then proceed with approval.
Virtual account issued, before depositAwaiting depositWAITING_FOR_DEPOSITNot startedDo not treat account issuance as received payment.
Approval and internal application completeOrder confirmedDONEAwaiting fulfillmentRun fulfillment separately.
Approved, but order DB update failedRecovery requiredDONE confirmed by lookupNot startedRecover internal application without charging again.
External pass-issuance response lostOrder confirmedPayment verifiedOutcome unresolvedQuery issuance before deciding the next action.
Order and fulfillment completeFulfilledPayment verifiedFulfillment completePreserve completion evidence; handle later corrections separately.
Payment cancellation verifiedFull/partial cancellation under review or appliedCANCELED / PARTIAL_CANCELEDReview stopping/revoking the affected scopeDistinguish 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.

How the service works

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 objectExample definition of the same operationWhat this does not protect
Gateway approval/cancellation requestLogical operation ID, request contents, provider-defined idempotency scopeInternal DB application or goods issuance
Internal payment recordGateway + merchant account + environment + gateway payment IDLater cancellation or correction events for that payment
Webhook receipt recordProvider-guaranteed event identifier and scopeThe same business effect represented by another event ID
Internal item/entitlement fulfillmentOrder-item unit + fulfillment action + necessary fulfillment versionWork already performed in an external issuer
External issuance requestFixed operation identifier/idempotency key supported by the issuerWork 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.

  1. 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.
  2. 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.
  3. 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.

How the service works

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 pointDifference to detectSafe next actionDeduplication or stopping condition
Order DB commit fails after approvalApproval 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 lostCall 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 togetherSeveral application attempts for one order/transactionSerialize through the common transition path; reread after conflict.Payment uniqueness plus item-level fulfillment constraints
Old approval notification arrives after cancellationObservation 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 publicationMessage delivery is uncertain.Allow the same event to be redelivered.Receiver processing records and business constraints
External issuance response is lostIssuance request exists; outcome evidence does not.Query the same operation’s issuance result.Stop automatic reissuance if unverifiable.
Deposit is confirmed after order expiryPayment 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 correctedEarlier 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 approvedMultiple 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.