8 min read·Run it here

Stripe: The Payment Intent Lifecycle

The core payment object and its status machine: create then confirm, branch on the status that comes back, sort failures by whose fault they are, and reconcile a local ledger against a paged remote list.

Pattern
create -> confirm -> branch on status; classify errors by type
Time
O(local + remote) reconcile, O(pages) list
Space
O(remote intents)

A PaymentIntent is not a charge. It is a state machine that Stripe drives and you observe, and almost every payments bug is someone treating one of its states as if it were another. This drill is the lifecycle end to end: start one, read where it landed, sort the failures by whose fault they are, stop one, and then check that your database and Stripe still agree.

The recipe

Say before you type: "Create, confirm, then branch on the status that came back. Failures split three ways by owner: the customer's, Stripe's, and mine."

Create and confirm are two calls

const created = await client.paymentIntents.create({ amount, currency, payment_method: pmId });
const intent = await client.paymentIntents.confirm(created.id);

amount is an integer in minor units. 2000 is twenty dollars. There is no arithmetic in this method, and the moment you type * 100 you have charged two thousand. Say it out loud when you write the create call, because the interviewer is waiting to see whether you notice.

Confirm is addressed by id. The payment method was attached at create, so confirm needs no body.

The three landings

confirm hands back whichever state the card landed in, and only one of them is a failure.

statuswhat it meanswhat you return
succeededmoney moved{ status, id }
requires_action3DS, waiting on a human{ status, id, clientSecret }
requires_payment_methodthe card was declined{ status, id, declineCode }

requires_action is not a failure. It is a live payment parked on a challenge. The frontend needs intent.client_secret to finish it. Fold it in with the declines and every 3DS customer in Europe sees a screen saying their card was refused, while an authorizable payment sits there until it expires. This is the single most common version of the bug, and it is popular precisely because if (status !== "succeeded") return failure looks tidy.

On a decline, reach for last_payment_error.decline_code, not .code. code is Stripe's bucket (card_declined); decline_code is the bank's actual reason (insufficient_funds, lost_card, do_not_honor), and it is the only one worth showing anyone. Declines that are not card declines, expired_card among them, carry no decline_code at all, so that field is null, never undefined.

The decline taxonomy

The second way a payment fails is that the call throws. Stripe errors carry a raw type, and the whole point of the type is that it tells you who has to do something about it.

classify(error) {
  const type = error && error.type;
  if (type === "card_error") return { outcome: "declined" };           // the customer's
  if (type === "api_error" || type === "api_connection_error" || type === "rate_limit_error") {
    return { outcome: "retryable" };                                    // Stripe's
  }
  return { outcome: "bug" };                                            // yours
}
  • card_error is the customer's. Their card will not work. Return a reason and stop.
  • api_error, api_connection_error, rate_limit_error are Stripe's. Backoff and try again is a legitimate response. A 429 usually carries a Retry-After, and Stripe also sends a Stripe-Should-Retry hint: prefer the hint over your own guess.
  • invalid_request_error, idempotency_error, authentication_error are yours. Bad parameters, a reused key with different parameters, a wrong API key. Throw loudly, unwrapped. Retrying your own bug just makes more of it.
  • Anything you cannot name is a bug too, but be precise about which "cannot name" you mean. A TypeError thrown by your own code has no type and is a bug. That is a different animal from a transport failure where no response ever arrived: that one is retryable, and stripe-node's own _shouldRetry retries it, because the idempotency key is what makes asking again safe. The test is not "do I recognise this error," it is "did Stripe answer me at all." No answer means retry. A bad answer you cannot classify means stop. See the retry rules in stripe-idempotency.

One naming detail the interviewer may reach for: the snake_case type above is the raw REST shape, which is exactly what you get on last_payment_error. stripe-node wraps a thrown error in a class, so there err.type reads "StripeCardError" and the snake_case name moves to err.rawType. The API reference lists four raw types (api_error, card_error, idempotency_error, invalid_request_error); the rest of the taxonomy is library-level, and api_connection_error never comes off the wire at all because a connection failure has no response body. Branch on the wrong field and every decline falls through to your default, which in this design means you throw on a customer's expired card.

Retrying a card_error is the trap. It never succeeds, it annoys a customer whose card was never going to work, and every attempt is another decline on your account's fraud signals. Say the rule as a sentence: classification is not retry policy; pay reports the outcome and the caller decides.

And say the idempotency rule in the same breath, because "retryable" is meaningless without it:

await client.paymentIntents.create(params, { idempotencyKey });

The key goes in the options argument, not in the body. And do not invent one when the caller gave you none. A key generated fresh on each attempt is not an idempotency key, it is a second charge with extra steps.

Cancel has to check first

const intent = await client.paymentIntents.retrieve(id);
if (!CANCELABLE.has(intent.status)) {
  throw new Error("cannot cancel payment intent " + id + ": status is " + intent.status);
}
return await client.paymentIntents.cancel(id);

Cancelable: requires_payment_method, requires_confirmation, requires_action, requires_capture, processing. Terminal: succeeded and canceled.

canceled is terminal too. Guarding only against succeeded fires a second cancel at an intent that is already dead, which Stripe answers with an invalid_request_error you then have to explain in a postmortem. Read before you write, and put the refused status in the message, because the person reading that log at 2am needs to know which one it was.

Reconcile: page the whole list

const base = { limit: 100 };
if (sinceMs !== undefined) base.created = { gte: Math.floor(sinceMs / 1000) };

let startingAfter;
for (;;) {
  const page = await client.paymentIntents.list(
    startingAfter === undefined ? base : { ...base, starting_after: startingAfter }
  );
  all.push(...page.data);
  if (!page.has_more || page.data.length === 0) break;
  startingAfter = page.data[page.data.length - 1].id;
}

Four Stripe facts live in those eight lines, and interviewers check all four:

  • limit caps at 100. Ask for 500 and you get an invalid_request_error, not a bigger page.
  • Lists come back newest first.
  • The cursor is starting_after, set to the id of the last element of the page you just read. Reach for data[0].id and you walk in place.
  • created is UNIX seconds, not milliseconds. Date.now() straight into created.gte asks Stripe for intents created in the year 57000, which returns zero rows, which produces a reconciliation report claiming your entire ledger vanished. Divide by 1000 and floor.

Reconcile: three buckets, and only one of them is scary

  • missing means the local order's piId is nowhere in the remote list.
  • mismatched means the intent exists and disagrees, on amount or on status.
  • orphaned means a remote intent no local order claims.

The status comparison needs a stated table, not a vibe: paid means succeeded, canceled means canceled, and pending covers every requires_* plus processing.

A local order marked paid against an intent sitting at requires_payment_method is mismatched, not missing. The intent is right there. Your database says you were paid and Stripe says you were not, and that is the most expensive row in the report. Filing it under "missing" hides it in the bucket everyone assumes is a data-entry problem.

Two details that decide the shape of the answer:

  • Claim the intent before you compare it. An order claims its intent even when the two disagree, otherwise every mismatch is double-counted as an orphan and the report doubles in size.
  • Pick a precedence and say it. An order can differ on both amount and status. One row per order, amount first, so the output is deterministic and the reviewer knows you thought about it.

What the interviewer is listening for

They are not testing whether you memorized the status names. They are testing three sentences: requires_action is a live payment, the error type tells you who owns the failure, and the cursor is the last id of the page, and the clock is in seconds. Have all three ready, and say the minor-units one unprompted while you write the create call.