Key concepts
Status lifecycle reference
A polling loop only works if you know when to stop. This page is the canonical reference for the payment-intents state machine — every possible status, which states are terminal (your code can stop polling), and what triggers each transition.
/v2/payment-intents uses lowercase underscored status names (ready_for_quote, partially_completed).
/v2/payment-intents lifecycle
A payment intent moves through a small graph anchored on draft → executing → completed. Some terminal states are also reachable directly via cancellation or connector failure.
Happy path
draft → ready_for_quote → quoted → ready_for_execution
→ executing → completed
Reusable payment links diverge at executing: instead of completed, they transition to pending_link_payment and stay there as long as the link can accept new payer submissions.
Full state table
| Status | Terminal? | Triggered by | What to do |
|---|---|---|---|
draft | No | Returned by POST /v2/payment-intents (Create). | Build the payload further (recipients, instructions, quote) or dry-run to validate. |
ready_for_quote | No | Internal — Core has accepted the draft and is waiting for a quote selection. | Provide instructions.manualPayment.quoteId and selectedQuoteVariantId. |
quoted | No | A quote has been attached to the intent. | Optionally compile a plan with POST /v2/payment-intents/{id}/draft, then submit. |
ready_for_execution | No | Dry-run confirmed rule.status: enabled and no missing requirements. | Call POST /v2/payment-intents/{id}/submit. |
executing | No | Submit dispatched to the connector. | Poll. Expect completed, pending_link_payment, partially_completed, failed, or requires_recovery within seconds-to-minutes. |
pending_link_payment | No (parent stays here for the link's life) | Submit on a deliveryMode: "reusable_payment_link" intent. | The hosted URL is live. Monitor child submissions via filter[createdFrom.paymentIntentId]. |
completed | Yes | Connector confirmed the payment. | Reconcile and stop polling. |
partially_completed | Yes | On a split intent, one recipient leg cleared and another failed at the connector. | Inspect executions[] to identify the failed leg. The cleared leg is not automatically reversed — handle the partial state in your own ledger. |
failed | Yes (may transition to requires_recovery if Core later detects an inconsistency) | Connector explicitly rejected the dispatch. | Inspect the latest execution for the cause. Mint a new intent if you want to retry. |
requires_recovery | No | Core detected an inconsistent state (e.g. a connector callback missing past SLA, or a partial dispatch checkpoint). | Do not retry submit. Poll status. If it persists past ~1 hour, contact support. |
cancelled | Yes | Operator-cancelled, or auto-cancelled after extended time in draft. | Stop. Create a fresh intent if you want to retry. |
Terminal set
Stop polling when status is any of: completed, partially_completed, failed, cancelled.
pending_link_payment is not terminal at the parent level — the link can keep accepting child submissions for its lifetime — but it is the steady state for the parent. Track child submissions instead of polling the parent.
requires_recovery is not terminal. It usually resolves automatically as the connector catches up, but a long stay (>1 hour) warrants a support ticket.
Polling guidance
Poll the intent while it's in a non-terminal state:
- Poll every 30 seconds with ±20% jitter while non-terminal.
- After 5 minutes in
executing, back off to every 60 seconds. - Abandon to a reconciliation job after ~30 minutes of non-terminal polling.
- Always stop on a status in the terminal set above.
In code:
const TERMINAL_INTENT = new Set([
'completed',
'partially_completed',
'failed',
'cancelled',
]);
async function pollUntilTerminal(getStatus, {
initialDelay = 30_000,
maxDelay = 60_000,
giveUpAfter = 30 * 60_000,
} = {}) {
const start = Date.now();
let delay = initialDelay;
while (Date.now() - start < giveUpAfter) {
const status = await getStatus();
if (TERMINAL_INTENT.has(status)) return status;
if (Date.now() - start > 5 * 60_000) delay = maxDelay;
const jittered = delay * (0.8 + Math.random() * 0.4);
await new Promise((r) => setTimeout(r, jittered));
}
throw new Error('Gave up polling; queue for reconciliation.');
}
Next steps
- Manual payment with splits — full payment-intent walkthrough including status polling.
- Payment links — reusable-link status and child submissions.