Payment Intent Object
A payment intent is the server-side record of one payment's lifecycle — authorize, capture, void, refund — driven entirely from your backend. You create one with POST /v1/payment_intents; it's what every integration path ultimately charges through. This page documents the object's shape. For the how-to (auth/capture/void/refund, MIT, 3DS), see the Payment Intents guide.
{
"id": "vpi_live_8x4n2pq7m1",
"status": "succeeded",
"amount": 4999,
"currency": "USD",
"capture_method": "automatic",
"card": { "brand": "visa", "last4": "4242" },
"created_at": "2026-06-23T15:30:00.000Z"
}
Core fields
| Field | Type | Always Present | Description |
|---|---|---|---|
id | string | Yes | Payment intent ID — vpi_test_* (sandbox) or vpi_live_* (live). |
status | string: requires_action | authorized | captured | succeeded | voided | failed | Yes | Current lifecycle status — see Status lifecycle. |
amount | integer | Yes | Amount in minor units (cents), ≥ 0. |
currency | string | Yes | ISO 4217 currency code (3-letter uppercase). |
capture_method | string: automatic | manual | Yes | automatic — capture immediately on a successful authorization. manual — authorize now, capture later via /capture. |
next_action | object | null | No | Present when a server-redirect 3DS challenge is required: { "type": "redirect_to_url", "redirect_to_url": { "url": "…" } }. Redirect the buyer to url; the intent settles after the challenge. |
client_confirm | object | null | No | Browser-confirmation handle: { binder, client_secret }. Do not build on it: handle a 3-D Secure challenge with next_action, which is the path for a charge sent with payment_method. ⛔ client_secret is a credential whenever present: it authorises the 3-D Secure step-up for this intent. Forward it only to the buyer's browser and redact it everywhere else: a payment result written whole to a log, an APM breadcrumb or an error tracker takes this field with it. |
decline_code | string | null: card_declined | insufficient_funds | expired_card | incorrect_cvc | incorrect_zip | card_velocity_exceeded | fraudulent | stolen_card | lost_card | do_not_honor | issuer_unavailable | processing_error | blocked_by_rule | generic_decline | No | Normalized decline reason on a failed intent (see Decline codes). |
rule_code | string (≤ 64) | null | No | Which of your own provider rules refused the charge. Present only when decline_code is blocked_by_rule; null on every other outcome. Carries the custom code you set on that rule, with the provider's own prefix stripped — max 64 chars, and null if you left the rule unnamed. See Telling your rules apart. |
action | string | null: soft | hard | fix_and_retry | generic | No | What to do about a decline — soft, hard, fix_and_retry or generic, derived from decline_code through one published mapping (see action). |
decline_message | string | null | No | Buyer-safe wording for the decline — the field to show a shopper. Not the same string as failure_reason, which is written for you and names the fraud, lost and stolen signals. See Showing a decline to the buyer. |
payment_method_id | string | null | No | The saved card this payment vaulted (vp_pmt_*), reusable for later merchant-initiated charges. null when the payment vaulted nothing — a guest payment with no buyer attached, or one that never succeeded. Returned by GET /v1/payment_intents/{id} only — it is not on the create response, because the card is vaulted when the charge completes. That makes this the surface to use when the token never reached your server at charge time: a payment sent through 3-D Secure finishes after your charge call has already returned, so the charge response carries no token for it. Unlike next_action, it is not tied to status — a saved card outlives the payment that saved it, so later reads keep returning it. Same value as token on the charge-at-submit response and id on a POST /v1/tokens response. |
card | object | null | No | PCI-safe card presentation — { "brand": …, "last4": … }. brand is one of visa, mastercard, amex, discover, diners, jcb, unionpay, unknown; last4 is 4 digits. No PAN, BIN, or fingerprint is ever included. |
avs_result_code | string | null: match | partial_postal | partial_address | no_match | unavailable | not_supported | No | Normalized Address Verification (AVS) result. null when no billing address was sent or no result was returned. Gateway-independent — raw processor codes are not exposed. |
cvv_result_code | string | null: match | no_match | not_provided | unavailable | No | Normalized card security-code result. |
created_at | string | null | No | ISO 8601 creation timestamp. |
metadata | object | No | Merchant-provided key-value pairs you set at creation. |
buyer_email | string (≤ 254) | No | On the create response, echoes back the buyer_email you sent, confirming it was accepted for buyer attribution. On a retrieve, capture or void it is the effective buyer's stored email, so it can be present even when that call sent no email (the same effective-buyer rule as buyer_id below). Omitted entirely when there is none to report: it is never null, so test for the key's presence rather than comparing to null. |
buyer_id | string (≤ 200) | No | Omitted when no effective buyer exists — never null. Echoes the effective buyer this charge was attributed to — which is not always the one you sent. If the stored payment_method you charged carries its own buyer, that one wins; otherwise it is the buyer_id on your request. Present only when an effective buyer exists. ⚠️ On an idempotency-key replay this echoes what this request asked for, not what is stored — the buyer write is skipped entirely on a replay, so a re-sent create that adds or changes a buyer returns the new value while the stored attribution keeps the original. On a first (non-replay) request it is a genuine confirmation that the best-effort linkage landed; on a replay it is not. |
buyer | object | No | Echoes the nested buyer object you sent at creation, confirming it was accepted. buyer.metadata is echoed after privacy scrubbing, so it reflects what was stored rather than what you sent. Omitted when you did not send one — never null. See Buyers. |
buyer_ip_address_forwarded | boolean | No | Whether the buyer_ip_address you sent was actually passed to the processor. Present only on the create response, and only when you sent one: the shared projection behind retrieve, capture and void does not carry it, so read it from the charge response rather than a later fetch. true means it was forwarded. false means we accepted it and did not send it, either because the charge was merchant-initiated (no shopper is present, so no address is asserted) or because the payment connection your account routes through is not one that carries it. Treat false as "this charge got no IP-based screening" rather than an error. The IP address itself is never echoed back. See Buyer IP address. |
resolved_descriptor | object | null | No | What actually went to the payment provider as the statement descriptor for this charge — present on the create response. One of { "kind": "skipped", "reason": … } when none was applied, { "kind": "suffix", "suffix" } for a dynamic tail on your registered prefix, or { "kind": "descriptor", "descriptor": {…} } for a full block. kind never names a payment provider. ⚠️ The brand-name prefix is best-effort and is usually fixed by your processor's merchant-account registration, not by this value; the dynamic tail is the part you reliably control per transaction. See Statement descriptors. |
pulseToken | string | null | No | Short-lived signed token for the real-time status subscription — forward it to the browser so the SDK's subscribeToPaymentIntent can resolve handleAction() over Server-Sent Events the instant a terminal status lands. null when the real-time substrate is unavailable; fall back to your timeout path. Opaque to your code — don't log it. ⚠️ The name is camelCase deliberately — it is the only field on this object that is, so pulse_token silently reads as nothing and keeps doing so. ⚠️ The stream this token is for is not generally available today, so in practice this arrives null and the fallback path is the live path — see Streaming endpoints. |
three_d_secure | object | null | No | What cardholder authentication (3-D Secure) actually did on this payment, as opposed to what was requested or configured: outcome is one of authenticated, attempted, not_authenticated, cancelled (the shopper abandoned the challenge), failed, not_performed or unknown, alongside method, version and eci. Read eci for liability; never treat attempted as authenticated. After a bank challenge the result is recorded once the payment settles, from the record kept by whoever ran the check. It stays null when the shopper abandons the challenge (no charge, so no result) and on a connection that reports no authentication record. null and outcome: "not_performed" are different answers: null means no concluded result was recorded, never "not authenticated"; not_performed means the payment was inspected and no authentication took place. |
Status lifecycle
| Status | Meaning |
|---|---|
requires_action | A 3D Secure (or other) challenge must complete before the intent can settle. Send the buyer to next_action.redirect_to_url.url. With no next_action the payment is still processing: wait for the webhook, which can take up to a day. |
authorized | Funds are held but not captured. Only reachable with capture_method: "manual" — capture with /capture or release with /void. |
captured | A previously authorized intent was captured. Not terminal — it transitions on to succeeded when settlement confirms (or failed if the provider rejects it). ⚠️ A refund requires succeeded: POST /v1/refunds rejects a captured intent. Wait for succeeded before refunding. |
succeeded | Money has moved, and this is terminal. Reached from captured when settlement confirms, from authorized when a manual capture settles in the same call, or directly on capture_method: "automatic". |
voided | An authorized intent was cancelled before capture. No money moves. |
failed | The authorization or capture failed. See decline_code for the reason. |
┌─► succeeded settlement confirmed
┌─► captured ───┤
│ └─► failed provider rejected the capture
│
┌─► authorized ─┼─► succeeded capture settled in the same call
│ ├─► voided released before capture
│ └─► failed
│ capture_method: "manual"
│
requires_action
│
├─► succeeded capture_method: "automatic"
└─► failed declined (decline_code set)
terminal: succeeded · voided · failed
refunds: POST /v1/refunds accepts succeeded only
authorized can reach succeeded without passing through captured: on card
networks the capture and the settlement happen in the same call, so the intent
moves straight to its terminal status. Do not wait to observe captured — read
the status you are given, and treat succeeded as the signal that money moved.
The client-side result of a charge is a UX signal only. Always confirm settlement server-side via the payment_intent.succeeded / charge.succeeded webhook before fulfilling.
Decline codes
When status is failed, decline_code carries the normalized reason (branch on this rather than a raw issuer code), and action tells you what to do about it. Both are null on any non-failed intent.
201, not an errorA declined payment intent returns 201 Created with status: "failed" — 200 on an idempotent replay. The request succeeded; the payment did not. There is no 4xx for a decline.
So parse the response body on success statuses. A client that only reads the body when the status is an error will never see decline_code, action, rule_code or decline_message — and the failure is silent: no exception is raised, the decline branch simply never runs, and every decline looks like a blank result.
action — retry or don't
action is derived from decline_code through one published mapping, so you can build retry and dunning logic without maintaining your own code table.
action | Meaning | What to do |
|---|---|---|
soft | Transient. | A later retry of the same card can legitimately succeed. |
hard | Terminal. | Do not retry. The buyer needs a different payment method. |
fix_and_retry | The buyer mistyped something. | Retry only after they correct it — don't retry unchanged. |
generic | No reason supplied. | Treat as hard unless you have a reason not to. |
decline_code | action |
|---|---|
insufficient_funds, issuer_unavailable, processing_error, card_velocity_exceeded | soft |
stolen_card, lost_card, fraudulent, do_not_honor, blocked_by_rule | hard |
expired_card, incorrect_cvc, incorrect_zip | fix_and_retry |
card_declined, generic_decline | generic |
blocked_by_rule is a routing rule you configured refusing the charge before any issuer saw it — its action is hard, and the rule that fired is reported as rule_code (null if you gave the rule no code). The full value reaches secret-key surfaces (this object and the charge.failed webhook, where it is named failure_code); browser-facing responses report it as card_declined with no rule_code. Capture both on the failure — GET /v1/payment_intents/{id} does not re-serve decline detail. On a provider that does not distinguish a rule refusal it arrives as generic_decline. Handling.
3D Secure: next_action
When a requires_action intent carries next_action, send the buyer to next_action.redirect_to_url.url, having set return_url on the create call. That is the same whether you use Embedded Fields or call the API directly. If that redirect never reached the buyer, read the intent again: GET /v1/payment_intents/{id} returns the same next_action, with issued_at for its age, for as long as the intent is requires_action.
A requires_action intent with no next_action is still processing: there is nothing for the buyer to do, so do not retry the charge, and wait for the webhook. That can take up to a day, because when the processor sends no webhook a daily reconciliation records the outcome, so do not put a shorter timeout on this state. See Embedded Fields → 3D Secure.
Related
- Payment Intents guide — the auth / capture / void / refund lifecycle, MIT, and recurring
- Session Object — the hosted-checkout session record
- Refunds — refunding a captured intent
- Error codes —
decline_codehandling and recovery - Webhooks —
payment_intent.*event payloads