Skip to main content

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.

Webhooks first, polling as a fallback
  1. 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.
  2. Read on a trigger, never on a timer. GET a 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.
  3. 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:

ResourceQuestion it answersRead 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​

statusFinalWhat it meansWhat it asks of you
opennoCreated; 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_actionnoThe 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.
completedyesThe 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).
failedyesThe 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.
expiredyesexpires_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.
canceledyesCancelled - 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.

Expiry is applied when the session is next used

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.

statusFinalWhat it meansWhat it asks of you
initialnoCreated on the platform, not yet sent to a provider.Nothing.
pendingnoWaiting 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.
processingnoSent 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.
completedyesThe provider accepted it. Money moved (or, for an authorization, is held).Fulfil, book, or capture.
failedyesDeclined, 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.
canceledyesCancelled before it completed - a void released the hold, or the session was cancelled or expired.Release.
returnedyesThe 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:

FlowFulfil onAlso handle
Hosted checkoutcheckout.session.completedcheckout.session.expired, .failed, .canceled - release the order.
Hosted checkout, capture_method: manualpayment.succeeded (fires when the capture settles)payment.authorized (the session completed; funds are held), payment.capture_failed.
Direct payment (APM)payment.succeededpayment.failed - final, no retry on the same payment. payment.requires_action if you want to track the redirect.
Crypto, direct or hostedpayment.succeededpayment.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.succeededpayment.failed, payment.authorized (manual capture), payment.requires_action (3-D Secure, handled in the SDK).
Card, saved card with collect_cvv: falsepayment.succeededpayment.failed, payment.authorized (manual capture), payment.requires_action (3-D Secure, handled by your server).
Card, merchant-initiatedpayment.succeededpayment.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 2xx at once, de-duplicate on the envelope id, 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​

TriggerWhat to readWhy
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​

FlowPollAlternative
Hosted checkoutGET /v1/checkout/sessions/{id}GET /v1/checkout/sessions/by-reference/{merchant_reference} (most recent session for the reference)
Direct payment, card server-to-serverGET /v1/transactions/{id} with the transaction_id the create returnedGET /v1/orders/{id} for the net result across attempts
Capture, void, refund, payout, transferGET /v1/transactions/{id} with the transaction_id that call returnedGET /v1/payouts/{id}, GET /v1/transfers/{id}
Reconciliation jobPOST /v1/transactions/search with status: ["initial", "pending", "processing"] and created_fromPOST /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​

  1. 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 settling or requires_action response.
  2. 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.
  3. Stop on a final status - completed, failed, canceled, returned, expired - and apply it.
  4. 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.
  5. 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.
  6. Honour 429. Back off and retry after the delay in Retry-After when present. A 429 on 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 GET has no side effects; repeating it is always safe. Verify the X-Signature on 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 completed after the webhook did.
  • Never "poll" by re-creating. A retried POST with the same merchant_reference is rejected as a duplicate; use the by-reference read to recover the resource instead (Conventions).

Next​