Skip to main content
Soap models a payment with three separate entities. Most integration bugs come from treating them as one, so it is worth reading this page before you write a webhook handler or design your ledger. A checkout has zero or more charges — none until the customer submits a payment, then one per attempt. A checkout has at most one open review at a time; a checkout whose approved attempt later fails can be reviewed again on the next attempt.
Despite the naming, most checkout.* webhooks are charge events: they report a transition of the underlying charge, and their payload carries a charge object. Only checkout.expired and checkout.terminally_failed describe the checkout itself, and neither carries a charge. Customer events (customer.kyc.created, customer.restricted, customer.unrestricted) are neither — they have no charge or subscription.So checkout.succeeded means the charge succeeded, not the session reached a successful end state.

Charge Status Lifecycle

A charge starts at created and moves through the transitions below. failed, returned, cancelled, voided and refunded are terminal — no further transition is possible. Note that succeeded is not terminal. A settled charge can later be reversed by the processor, which is why you can receive checkout.failed with from_status: "succeeded" for a payment you already credited.

Which transition emits which webhook

Not every status has a corresponding event. created has none.
cancelled is processor-specific. It applies when a transfer that was accepted for processing (usually pending) is cancelled by the rail; today that is bank-transfer rails, for deposits and payouts alike. Card rails do not use it. Ask Soap whether your rails emit it before you subscribe. See checkout.cancelled.refunded is set when a succeeded card charge is refunded — via the merchant dashboard or POST /api/v1/charges/{id}/refund — and fires the opt-in checkout.refunded event when enabled.
Two details about failed that matter when reconciling withdrawals:
  • The same status produces two different events depending on where the charge came from. A withdrawal that fails directly from held emits checkout.release_hold; a withdrawal that fails from any other status emits checkout.failed, which is off by default. What checkout.failed means depends on its from_status: pending means the payout was accepted for processing and then failed, so release the hold exactly as you would on checkout.release_hold; created means your checkout.hold endpoint rejected the attempt, so nothing was debited and there is nothing to release; succeeded means a completed payout was reversed after the fact and needs its own handling (see the withdrawal guide).
  • The pending case is the normal failure shape on asynchronous rails (bank transfers and some card rails): the charge was no longer held when it failed, so it emits checkout.failed, not checkout.release_hold. If your payouts can go pending, enable both checkout.pending and checkout.failed.
  • checkout.hold is delivered before the charge moves to held. If your endpoint does not return success, Soap fails the charge while it is still created — so you receive checkout.failed, not checkout.release_hold. See checkout.hold.

One Checkout, Several Charges

Each payment attempt creates a new charge with a new ch_ id. When an attempt fails, the customer returns to the payment form; their next submission is a new attempt against the same checkout. A customer whose card is declined and who then pays with a different method leaves behind two charges: one failed, one succeeded.
Key your ledger on data.charge.id, not on the checkout id. A checkout id is not unique per payment, so using it as your idempotency or reconciliation key will mis-book retries.
Soap enforces at most one charge per attempt. It does not limit how many attempts a checkout can accumulate, so do not assume a fixed number of charges per checkout. Two consequences worth designing around:
  • There is no endpoint that lists a checkout’s charges. You learn each charge’s id from the webhook that announces it and read it back with GET /api/v1/charges/{id}.
  • Because checkout.failed is off by default, a merchant who has not enabled it receives no notification of earlier failed deposit attempts and has no charge id for them. Withdrawal attempts are different: every attempt that reaches a hold announces its charge id on checkout.hold, and a held attempt that fails straight from held emits checkout.release_hold, which is always sent. A held attempt that fails after going pending emits checkout.failed instead, so enable checkout.failed in Dashboard → Developers for withdrawals as well as for attempt-level deposit history.

Checkout Outcomes

A checkout is in progress from creation until the customer finishes it or it expires, which is 30 minutes after creation by default. There is no checkout status field, and no endpoint to poll a checkout’s state after you create it — you observe outcomes through webhooks.
There is no terminal-success event for the checkout itself. Success is signalled by the charge events, which is why the list above mixes charge-level and checkout-level events.Both checkout.expired and checkout.terminally_failed are off by default, so a merchant who has enabled nothing extra receives no webhook at all for an abandoned or blocked checkout.

Reviews

Some withdrawals are routed to manual review before any payment is attempted. Review is disabled by default and enabled per account, and the criteria that select a withdrawal for review are configured per account — so some withdrawals enter review and others do not. Today every checkout.review.* event you receive will carry "type": "withdrawal". A review is created before the reviewed attempt’s charge exists. Soap creates the review and pauses the checkout instead of submitting the payment, which means:
  • No charge exists for the attempt under review, so you will receive no charge-based webhook for that attempt while the review is open. (Charges from earlier attempts on the same checkout, if any, keep their own status.)
  • On approval, Soap creates the charge and places the hold at that point — checkout.hold arrives then, followed by the normal withdrawal events.
  • On decline, no charge is created for that attempt. Neither checkout.failed nor checkout.release_hold can fire, because there was never a charge to fail.
A declined withdrawal produces no charge-failure webhook. If you reconcile solely on checkout.failed or checkout.release_hold, declined withdrawals will be invisible to you. Subscribe to checkout.review.declined to observe them — it is off by default, as are all three review events.
Reviews are resolved by a person in the Soap dashboard. There is no timeout, no auto-approve and no auto-decline, and there is no public endpoint to approve or decline a review.

Disputes and Chargebacks

A card chargeback is recorded as a dispute on the charge. It does not change the charge’s status: the charge stays succeeded, so none of the charge events (checkout.returned, checkout.failed, checkout.refunded) fire for it. There is no dispute-specific webhook today. What you receive when a dispute is opened:
  • customer.restricted with reason_code: "DISPUTE", once for deposit_restricted and once for withdrawal_restricted, if you have enabled that event. This is the machine signal that a dispute exists for that customer.
  • An email to every admin user of your dashboard account with the dispute id, the processor’s reason category, the issuer’s request and a link to the case.
  • The case itself under Disputes in the dashboard, linked to the original charge, where evidence is gathered and the response is prepared.
You lift the DISPUTE restriction yourself in the dashboard once the case is resolved in your favour or you decide the customer is legitimate.

Returns, disputes and refunds side by side

checkout.refunded fires only for refunds initiated by you (or by Soap on your request) through POST /api/v1/charges/{id}/refund or the dashboard. Chargebacks and returns never produce it.

failure_code

failure_code and failure_message on charges and charge events are the processor’s own decline or failure codes, passed through unchanged, plus one Soap code, SOAP_CHECKOUT_HOLD_FAILED, set when your checkout.hold endpoint declined a withdrawal. Card gateways, bank rails and wallets each use their own vocabulary, so there is no single list to map against; treat the fields as support information rather than as a switch. They are null on checkout.returned, and disputes are not represented in them at all.