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 draftexecutingcompleted. 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

StatusTerminal?Triggered byWhat to do
draftNoReturned by POST /v2/payment-intents (Create).Build the payload further (recipients, instructions, quote) or dry-run to validate.
ready_for_quoteNoInternal — Core has accepted the draft and is waiting for a quote selection.Provide instructions.manualPayment.quoteId and selectedQuoteVariantId.
quotedNoA quote has been attached to the intent.Optionally compile a plan with POST /v2/payment-intents/{id}/draft, then submit.
ready_for_executionNoDry-run confirmed rule.status: enabled and no missing requirements.Call POST /v2/payment-intents/{id}/submit.
executingNoSubmit dispatched to the connector.Poll. Expect completed, pending_link_payment, partially_completed, failed, or requires_recovery within seconds-to-minutes.
pending_link_paymentNo (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].
completedYesConnector confirmed the payment.Reconcile and stop polling.
partially_completedYesOn 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.
failedYes (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_recoveryNoCore 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.
cancelledYesOperator-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

Previous
Authentication