---
id: docs.accept-a-payment
title: "Accept a payment"
canonical: https://docs.secureprocessing.app/accept-a-payment
last_verified: 2026-11-06
---

# Accept a payment

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

## The shape of it

| Step | What happens |
|---|---|
| You create a payment intent | We record what you intend to charge. |
| You create a checkout session | You get a URL. |
| Your customer pays | On a page hosted by the bank. You never see the card. |
| We verify with the bank | We re-read the payment rather than trusting a notification. |
| We send you a webhook | payment_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",
});
```

> **Note:** `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 happened | What 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 confirming | 3-D Secure, or the bank has not finished. The page refreshes itself until it knows. |
| Changed their mind before paying | Your `cancel_url` is a “Cancel and return” link on our checkout page, before they reach the bank. Without one, there is no link. |

> **Note:** 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.
