# Status lifecycle reference

> The authoritative state machine for /v2/payment-intents, with terminal-state markers and transition triggers.

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:

```javascript
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](/docs/guides/payment-intents-manual.md) — full payment-intent walkthrough including status polling.
- [Payment links](/docs/guides/payment-links.md) — reusable-link status and child submissions.
