Skip to content
On this page

Developers › Payments

Accept a payment

The ordinary case: charge a card now, and find out when it worked.

Verified 6 November 2026Explain this to your team →

View as Markdown

The shape of it

StepWhat happens
You create a payment intentWe record what you intend to charge.
You create a checkout sessionYou get a URL.
Your customer paysOn a page hosted by the bank. You never see the card.
We verify with the bankWe re-read the payment rather than trusting a notification.
We send you a webhookpayment_intent.succeeded. Fulfil the order here.

Creating the payment

ts
const intent = await sp.paymentIntents.create({
  amount: 6000,
  currency: "usd",

  // Shown on the payment in your dashboard, and sent to the bank with it.
  // Lead with your own reference so you can find it again.
  description: "SCU-B0161 — Blue Hole Two-Tank",

  // Your identifiers. The payments list can be filtered by these.
  metadata: { shop_id: "scuba-sensation", booking_id: "SCU-B0161" },

  // Optional: the bank emails a receipt to this address.
  receipt_email: "diver@example.com",
});
description is the only field you control that is searchable in the bank’s own records, and it is frozen when the payment is taken. Your metadata stays with us; the amount, the currency and receipt_email reach the bank too, but none of them is what you will be searching on when you are trying to find one payment among a day of them. Put the reference you would search for first — a bare noun like “Booking” makes a bank statement impossible to reconcile.

metadata takes the same shape on every object that has it: at most 50 keys, each up to 40 characters and without square brackets, and string values of up to 500 characters. Anything over is refused with a 400 whose param names it — metadata for too many keys, metadata.booking_note for one key or value. Sending metadata on an update replaces it whole. These are the same limits Stripe documents, so a payload that works there works here.

statement_descriptor_suffix is deprecated and has no effect. It is still accepted and stored, but it never reaches the bank and never appears on the cardholder’s statement: the bank has not confirmed any field that would carry it. Put the reference you need in description instead.

Sending the customer

ts
const session = await sp.checkoutSessions.create({
  payment_intent: intent.id,
  // {CHECKOUT_SESSION_ID} is replaced with this session's id when the customer is sent back.
  success_url: "https://example.com/thanks?session={CHECKOUT_SESSION_ID}",
  cancel_url: "https://example.com/cart",
  line_items: [{ name: "Blue Hole Two-Tank Dive", amount: 6000, quantity: 1 }],
});

redirect(session.url!);

line_items are shown on the checkout page so the customer can see what they are paying for. The payment’s own amount is what is charged — the line items are never summed.

A session is open for 24 hours. Its url becomes null once it is complete or expired, so a finished session cannot be paid twice. A payment has one open session at a time; to send the customer a fresh link, expire the old session first.

The bank’s card page never outlives the session that opened it. It is open for about twenty minutes at most, and a customer who reaches it near the end of the session gets only what the session has left, less a minute. With under two minutes to go, our checkout page shows the session as expired rather than sending the customer to a page that would close in their hands — although the session’s status reads open until expires_at itself.

success_url and cancel_url must be https:// in live mode. In test mode plain http:// is accepted too, so you can point them at your own machine. Any other scheme is refused with a 400 naming the field.

Where the customer goes

The customer starts on our checkout page, where they see the amount and press Pay by card. That takes them to the bank’s page to enter their card. When the bank is done with them it sends them back to our result page, never straight to yours — we confirm the outcome with the bank there before saying anything.

What happenedWhat the customer sees
Paid“Payment received”, and a Continue button to your success_url. A hold placed with capture_method: "manual" reads “Payment authorised” and continues the same way.
Declined“The payment did not go through”, with the reason we can show, and a button to try another card on the same session. They are not sent to your cancel_url, and you do not need to create anything new.
Still confirming3-D Secure, or the bank has not finished. The page refreshes itself until it knows.
Changed their mind before payingYour cancel_url is a “Cancel and return” link on our checkout page, before they reach the bank. Without one, there is no link.
Once the customer is on the bank’s page, what its own controls do is the bank’s — we have not confirmed where a customer who abandons that page ends up. The session stays open either way, and an unpaid one expires after 24 hours.

Knowing it worked

Fulfil on the webhook, not on the redirect. A customer can close the tab after paying, and a redirect can be replayed by anyone who sees the URL.

ts
if (event.type === "payment_intent.succeeded") {
  const payment = event.data.object;
  await fulfil(payment.metadata.booking_id);
}

If you also want to show the customer a result immediately, read the payment back when they land on your success_url. The session id you asked for with {CHECKOUT_SESSION_ID} leads to it:

ts
// https://example.com/thanks?session=cs_7hJ2kL9mN4pQ6rS8tU0vW3xY
const session = await sp.checkoutSessions.retrieve(url.searchParams.get("session")!);
const payment = await sp.paymentIntents.retrieve(session.payment_intent);
payment.status; // "succeeded"

That is a display decision; the webhook is the record. Anyone can type a session id into that URL, so look the payment up rather than believing the address bar.