Guide

A payment intent is one attempt to collect money from a customer. The payment provider (PSP) creates it, moves it through states, and reports the result. Your system builds its own view of the payment from those reports. That view must stay correct even when a call times out.

The big idea

Modern PSPs use a state machine rather than a single "charge" call:

create intent (requires_payment_method)
  → confirm (authorize)
  → optional capture (if capture is manual)
  → succeeded | canceled | requires_action (3-D Secure)

Two ways to learn the outcome:

StyleHow you learn the resultUse when
SynchronousThe confirm call returns success or failureA simple web checkout on a fast path
AsynchronousThe PSP sends a webhook, such as payment_intent.succeeded3-D Secure, delayed payment methods, mobile SDKs, anything that can pause

Always design for the asynchronous case. Even a "synchronous" confirm can time out after the PSP has already succeeded. The client sees uncertainty, and the webhook is the way out.

Walking through it

The happy path, with your payment service in the middle:

Now the uncertain case. Your service calls confirm, and the network times out:

You call PSP.confirm() → timeout
The true state could be: never received | processing | succeeded | failed

The safe policy has four parts:

  • Retry only with the same idempotency key.
  • Do not create a new intent "just in case".
  • Ask the PSP for the intent's status, or wait for the webhook.
  • Drive your local state from the PSP's answer and your ledger, not from "the HTTP call returned 200".

Keep your own explicit status column too:

created → requires_action → authorized → captured → settled
                ↘ declined
authorized → voided
captured → refunded (partial or full)

Transitions are enforced by code and database constraints. Side effects, such as a receipt email or fulfilling the order, fire once, when a transition happens, and are gated by an outbox event for that payment and transition.

Common mistakes

  • Treating a synchronous confirm as final. A timeout can hide a success.
  • Using the PSP's status strings as your model. Keep your own status, so a PSP change does not break your logic.
  • Creating a new intent on a retry. That can charge the customer twice.

Check yourself

  1. A confirm call times out. What state is the payment in?

    Answer: unknown. It may never have arrived, may still be processing, or may have succeeded. Ask the PSP for the status, or wait for the webhook.

  2. Why keep your own status column instead of reading the PSP's status?

    Answer: your transitions are enforced by your code and database, and your logic does not depend on the PSP's strings.

Key takeaways

  • A payment intent moves through states, and each confirm can end in an unknown outcome.
  • Design for asynchronous results, and treat webhooks as the path to completion.
  • On a timeout, retry with the same key and check the status. Never create a second intent.
  • Keep your own status and enforce its transitions.

Further learning

Next, see how the idempotency keys make these retries safe at each hop, in idempotency at every hop.