Outcomes
Payments are asynchronous. Creating a session or a payment starts a process; the outcome arrives later - when the customer finishes on the hosted page, when the provider confirms, when a 3-D Secure challenge completes. This page explains the status model, how to receive the outcome over webhooks, and how to poll when you have to.
- Act on webhooks. Register an endpoint, verify the signature, process idempotently. Webhooks are authoritative, arrive within seconds of the outcome, and cost you no API requests.
- Read on a trigger, never on a timer.
GETa session or transaction when something gives you a reason to - the customer came back, a webhook is overdue, a reconciliation job is due. Do not start polling the moment you create a resource. - Branch on final vs. in flight. Treat every non-final status the same way: wait. Only a final status changes what you do.
The status model
Three resources carry a status, and each answers a different question:
| Resource | Question it answers | Read it with |
|---|---|---|
Checkout session (cs_…) | Has the customer paid on this hosted checkout? | GET /v1/checkout/sessions/{id} |
Transaction (txn_…) | What happened to this one attempt at the provider? | GET /v1/transactions/{id} |
Order (ord_…) | What is the net result of all attempts for this payment? | GET /v1/orders/{id} |
For a hosted checkout, watch the session: it owns the customer's retries, and it is what the
checkout.session.* webhooks report. For a direct payment or a card charge there is no
session; watch the transaction whose id the create returned (or the order, if you prefer one answer
per merchant_reference). POST /v1/payments itself returns a payment status
(completed, requires_action, authorized, settling, canceled, failed) - that is a snapshot
of the moment the call returned, not a resource you poll. See the
Glossary for every enum and
Conventions for how they are serialised (lowercase).
Checkout session statuses
status | Final | What it means | What it asks of you |
|---|---|---|---|
open | no | Created; the customer has not paid. A session also returns here after a declined attempt while max_payment_attempts is not exhausted. | Nothing. Keep the order unfulfilled and wait. |
pending_action | no | The customer submitted a payment and the attempt is waiting on an external step: a provider redirect, a 3-D Secure challenge, a QR code or voucher. The session's next_action says which. | Nothing. The session moves to completed, or back to open if the attempt is declined and attempts remain. |
completed | yes | The customer paid. transaction_id is the settled transaction. On a session created with capture_method: manual this means authorized, not captured - see Manual capture on hosted checkout. | Fulfil the order (or capture the hold). |
failed | yes | The last permitted attempt was declined; max_payment_attempts is exhausted. | Release the order. Read the decline code on the last transaction. To try again, create a new session. |
expired | yes | expires_at passed without a completed payment (one hour after creation unless you set it). | Release the order. Create a new session if the customer returns. |
canceled | yes | Cancelled - by you with POST /v1/checkout/sessions/{id}/cancel, or by the customer on the hosted page. | Release the order. |
The transitions are: open ⇄ pending_action, and from either of those to any final status.
A final status never changes. Note the loop: pending_action → open is normal, and so is a
failed transaction under a session that is still open - the customer gets another try.
A session is marked expired when the hosted page or the API next touches it after expires_at,
so a GET can still return open for a while after that time. Treat expires_at as your
deadline: once it has passed, stop polling and release the order. Keep listening to webhooks - an
attempt the customer already started at the provider still reports its outcome when it finishes.
Transaction statuses
A transaction is one attempt at the provider: a payment, an authorization, a capture, a void, a refund, a payout, a transfer. Its status is the provider-level truth for that attempt.
status | Final | What it means | What it asks of you |
|---|---|---|---|
initial | no | Created on the platform, not yet sent to a provider. | Nothing. |
pending | no | Waiting on an external step before or while the provider acts - a refund, payout or transfer queued for execution; a confirmation the provider still owes. | Nothing. |
processing | no | Sent to the provider, outcome not yet known. A payment waiting for the customer to finish at the provider, or in a 3-D Secure challenge, sits here. | Nothing. |
completed | yes | The provider accepted it. Money moved (or, for an authorization, is held). | Fulfil, book, or capture. |
failed | yes | Declined, or the attempt errored. Final for this attempt - under an open session the customer may retry, which creates a new transaction. | Read decline_code. Decide whether a retry is worthwhile. |
canceled | yes | Cancelled before it completed - a void released the hold, or the session was cancelled or expired. | Release. |
returned | yes | The funds arrived but were sent back to the payer, after you declined an under- or overpayment. | Release. |
The allowed transitions are initial → pending or processing → completed, failed,
canceled or returned. Do not branch between initial, pending and processing: they differ in where the
attempt is, not in what you should do, and which one a given provider passes through is not
guaranteed. Treat all three as in flight.
Order statuses
The order sums up its transactions: pending, processing, requires_action, authorized,
succeeded, failed, canceled, returned. succeeded, failed, canceled and returned are
final. A declined attempt that leaves retries available returns the order to pending; a
manual-capture hold sits in authorized until the authorization closes; an
under- or overpayment sits in processing until you decide it, and ends
returned if you send it back. Use the order when you want one answer for a
merchant_reference regardless of how many attempts it took.
Webhooks: the primary channel
Webhooks tell you the moment a status becomes final, with the full payload, signed. Build your integration on them. The event to fulfil on depends on the flow:
| Flow | Fulfil on | Also handle |
|---|---|---|
| Hosted checkout | checkout.session.completed | checkout.session.expired, .failed, .canceled - release the order. |
Hosted checkout, capture_method: manual | payment.succeeded (fires when the capture settles) | payment.authorized (the session completed; funds are held), payment.capture_failed. |
| Direct payment (APM) | payment.succeeded | payment.failed - final, no retry on the same payment. payment.requires_action if you want to track the redirect. |
| Crypto, direct or hosted | payment.succeeded | payment.underpaid / payment.overpaid - decide whether to accept. payment.returned - release the order; on a hosted checkout it follows a checkout.session.completed. |
Card, Upprove.js (collect_card / collect_cvv) | payment.succeeded | payment.failed, payment.authorized (manual capture), payment.requires_action (3-D Secure, handled in the SDK). |
Card, saved card with collect_cvv: false | payment.succeeded | payment.failed, payment.authorized (manual capture), payment.requires_action (3-D Secure, handled by your server). |
| Card, merchant-initiated | payment.succeeded | payment.failed, payment.authorized (manual capture). Never payment.requires_action: no 3-D Secure runs. |
Three things make a webhook handler safe, and the existing pages cover each in full:
- Verify the signature on the raw body before you parse it, and reject what does not verify - Verifying webhooks.
- Acknowledge fast, process idempotently. Return
2xxat once, de-duplicate on the envelopeid, and expect events out of order - Retries → Idempotency. - Know the retry window. A failed delivery is retried on a backoff that spans about 27 hours, after which the delivery is exhausted - Delivery & retries. Polling exists to cover that last case, not to replace the first attempt.
Whatever channel a status arrives on, apply it with one rule: a record only moves forward. An in-flight status never overwrites a final one, and a final status is applied once. With that rule a webhook and a poll result can race without harm, and the same handler can take both.
Polling: the fallback
A read is cheap for you and for the platform when it has a reason. Polling becomes a problem when it runs on a timer from creation, for every resource, until something changes - that is how a modest integration ends up rate-limited (see Rate limits) while its webhook endpoint sits idle.
When to poll
| Trigger | What to read | Why |
|---|---|---|
The customer lands on your success_url or cancel_url before the webhook has been applied. | The session (hosted) or transaction (direct). | To render the right page. Fulfil on the webhook or on a read that returns a final status, never on the URL the customer arrived at. |
| Your create call timed out and you do not know whether it succeeded. | GET …/by-reference/{merchant_reference}. | Recover the id and state before retrying. Retrying the POST with the same reference returns 409 duplicate_merchant_reference_id. |
| A webhook is overdue, or your endpoint was down. | The resource by id. | Catch up on what you missed. |
| A scheduled reconciliation job. | POST …/search filtered to in-flight statuses. | Find anything stuck or missed, in bulk. |
| You have not registered a webhook endpoint yet. | The resource by id, on the schedule below. | Acceptable while integrating. Register an endpoint before go-live. |
Which endpoint
| Flow | Poll | Alternative |
|---|---|---|
| Hosted checkout | GET /v1/checkout/sessions/{id} | GET /v1/checkout/sessions/by-reference/{merchant_reference} (most recent session for the reference) |
| Direct payment, card server-to-server | GET /v1/transactions/{id} with the transaction_id the create returned | GET /v1/orders/{id} for the net result across attempts |
| Capture, void, refund, payout, transfer | GET /v1/transactions/{id} with the transaction_id that call returned | GET /v1/payouts/{id}, GET /v1/transfers/{id} |
| Reconciliation job | POST /v1/transactions/search with status: ["initial", "pending", "processing"] and created_from | POST /v1/checkout/sessions/search with status: ["open", "pending_action"] |
Do not poll a single resource through a search endpoint. Search is for reconciliation jobs; the by-id reads are
cheaper and return the same status.
The polling rule
- Start on a trigger, not on creation. For a hosted checkout the first sensible trigger is the
customer returning to your site; for a direct payment, the customer returning from the
provider; for a card charge, a
settlingorrequires_actionresponse. - Back off. Make the first read about 5 seconds after the trigger, then double the wait (10, 20, 40 s) up to a 60-second cap, with some jitter. One resource, one loop.
- Stop on a final status -
completed,failed,canceled,returned,expired- and apply it. - Stop on the deadline. For a session, the deadline is
expires_at: treat the session as expired on your side and release the order. For a transaction, stop the loop after about 15 minutes and hand the record to your reconciliation job. Some alternative payment methods (vouchers, bank transfers) legitimately take hours or days to complete, and the webhook will still arrive. - Reconcile on a schedule, don't loop. Run a reconciliation job every hour (daily is enough for low volume) that searches for in-flight records older than your polling window, reads each one, and applies the result. Anything still in flight well past its expected settlement goes to a human.
- Honour
429. Back off and retry after the delay inRetry-Afterwhen present. A429on a poll is a sign the loop is wrong, not a reason to retry faster.
With the schedule above, a session that lives for its default hour costs at most about sixty reads, and a payment that settles in a minute costs four. A one-minute timer from creation on every session costs the same sixty reads per session, whether or not anyone is paying.
Idempotent reads
- A
GEThas no side effects; repeating it is always safe. Verify theX-Signatureon every response you act on, exactly as for a webhook (Signing). - Feed poll results and webhook events through the same state handler, keyed on the resource id, with the forward-only rule above. Then it does not matter which arrives first, or twice.
- A read that returns the status you already hold is a no-op. Do not re-run fulfilment because a
poll returned
completedafter the webhook did. - Never "poll" by re-creating. A retried
POSTwith the samemerchant_referenceis rejected as a duplicate; use the by-reference read to recover the resource instead (Conventions).
Next
- Webhooks overview - registering endpoints and verifying events.
- Event types - every event and its payload.
- Delivery & retries - the retry schedule and idempotency.
- Rate limits - what happens when a polling loop goes wrong.