> ## Documentation Index
> Fetch the complete documentation index at: https://docs.paywithsoap.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Checkouts, Charges and Reviews

> How the checkout, charge and review entities relate, the charge status lifecycle, which webhook each transition emits, and why one checkout can have several charges.

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.

| Entity       | What it is                                                                                                                                                                    | Identifier  |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| **Checkout** | The session the customer completes payment in. Created by `POST /api/v1/checkouts`, completed at the hosted `url`. Also referred to as a *checkout session* — the same thing. | `chk_...`   |
| **Charge**   | A single payment attempt against that checkout, with its own independent status.                                                                                              | `ch_...`    |
| **Review**   | A manual approval step that can pause a checkout before any payment is attempted.                                                                                             | `cpilr_...` |

A checkout has **one or more** charges — one per payment attempt. A checkout has **at most one** review.

<Warning>
  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`](/api-reference/api-v1/webhooks/checkout-expired) and [`checkout.terminally_failed`](/api-reference/api-v1/webhooks/checkout-terminally-failed) describe the checkout itself, and neither carries a `charge`.

  So `checkout.succeeded` means *the charge succeeded*, not *the session reached a successful end state*.
</Warning>

## Charge Status Lifecycle

A charge starts at `created` and moves through the transitions below. `failed`, `returned`, `cancelled` and `refunded` are terminal — no further transition is possible.

| From        | Can move to                                |
| ----------- | ------------------------------------------ |
| `created`   | `succeeded`, `failed`, `pending`, `held`   |
| `pending`   | `succeeded`, `failed`, `cancelled`         |
| `held`      | `succeeded`, `failed`, `pending`           |
| `succeeded` | `returned`, `failed`, `voided`, `refunded` |

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. Three have none at all.

| Charge status                                   | Webhook                                                                         | Delivery                      |
| ----------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------- |
| `created`                                       | none                                                                            | Initial state, never notified |
| `pending`                                       | [`checkout.pending`](/api-reference/api-v1/webhooks/checkout-pending)           | Off by default                |
| `held`                                          | [`checkout.hold`](/api-reference/api-v1/webhooks/checkout-hold)                 | Always sent                   |
| `succeeded`                                     | [`checkout.succeeded`](/api-reference/api-v1/webhooks/checkout-succeeded)       | Always sent                   |
| `failed`, when the charge was previously `held` | [`checkout.release_hold`](/api-reference/api-v1/webhooks/checkout-release-hold) | Always sent                   |
| `failed`, from any other status                 | [`checkout.failed`](/api-reference/api-v1/webhooks/checkout-failed)             | Off by default                |
| `returned`                                      | [`checkout.returned`](/api-reference/api-v1/webhooks/checkout-returned)         | Always sent                   |
| `voided`                                        | [`checkout.voided`](/api-reference/api-v1/webhooks/checkout-voided)             | Off by default                |
| `cancelled`                                     | none                                                                            | Not notified                  |
| `refunded`                                      | none                                                                            | Not notified                  |

<Note>
  **`cancelled` and `refunded` are not notified.** `cancelled` applies to bank-transfer payments cancelled while still `pending`. `refunded` is set when Soap refunds a settled charge out-of-band — through a payment partner rather than through the API — and reconciles it afterwards; nothing produces it automatically, so it does not follow from anything you do through the API.

  Soap sends no event for either. If you need to observe them, read the charge with [`GET /api/v1/charges/{id}`](/api-reference/api-v1/charges/show).
</Note>

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 after a hold was placed emits `checkout.release_hold`; a withdrawal that fails before reaching `held` emits `checkout.failed`, which is off by default. Both mean you should release the hold.
* `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`](/api-reference/api-v1/webhooks/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`.

<Warning>
  **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.
</Warning>

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:

* The charge returned when you read a checkout is the **latest attempt's** charge. Earlier attempts are not listed on it.
* Because `checkout.failed` is off by default, a merchant who has not enabled it receives no notification of the earlier failed attempts and has no charge id for them. If you need attempt-level history, enable `checkout.failed` in Dashboard → Developers.

## 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.

| Outcome                                                  | How you observe it                                                                      |
| -------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| Payment succeeded                                        | `checkout.succeeded` (always sent)                                                      |
| Payment accepted but not yet settled                     | `checkout.pending` (off by default), then a later `checkout.succeeded` or failure event |
| Blocked by geo, KYC or fraud checks, cannot be continued | `checkout.terminally_failed` (off by default)                                           |
| Customer never completed it before the deadline          | `checkout.expired` (off by default)                                                     |

<Note>
  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.
</Note>

## 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 charge exists.** Soap creates the review and pauses the checkout instead of submitting the payment, which means:

* There is no charge during the review window, so no charge status can change and you will receive no charge-based webhook for that checkout while the review is open.
* 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 ever created**. Neither `checkout.failed` nor `checkout.release_hold` can fire, because there was never a charge to fail.

<Warning>
  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`](/api-reference/api-v1/webhooks/checkout-review-declined) to observe them — it is off by default, as are all three review events.
</Warning>

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.
