Case studies
Payment intents and states
How a PSP payment intent moves through its states, why every confirm can end in an unknown outcome, and why your own status column should be the one you trust.
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:
| Style | How you learn the result | Use when |
|---|---|---|
| Synchronous | The confirm call returns success or failure | A simple web checkout on a fast path |
| Asynchronous | The PSP sends a webhook, such as payment_intent.succeeded | 3-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 | failedThe 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
-
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.
-
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.
Related
Case study: payment system
Moving money correctly, with payment intents, idempotency at every hop, a double-entry ledger, webhooks and reconciliation when the network lies. Start here for the payments map.
Idempotency and retries
Making operations safe to retry, with idempotency keys, deduplicating consumers and jittered backoff, so at-least-once delivery never means a double charge.
Webhooks and outbox
How to receive PSP webhooks safely, with signature checks, fast acks and deduplication, and how the transactional outbox makes calls to the PSP survive a crash.
Idempotency at every hop
How idempotency keys protect a payment at each boundary, from the client to your API, from your service to the PSP and from the PSP's webhooks back to you, and why the key row must share the payment's transaction.