---
id: docs.payment-links
title: "Payment links"
canonical: https://docs.secureprocessing.app/payment-links
last_verified: 2026-11-06
---

# Payment links

One URL that takes a payment, over and over.

A payment link is a page we host, at a URL you can print, message or turn into a QR code. It charges one of your [prices](https://docs.secureprocessing.app/products-and-prices.md). Everyone who opens it gets their **own** payment — two people scanning the same code on your counter produce two payments, and both can pay.

There is nothing to build. No keys on your side, no page to write, no webhook you have to have working before you can take the first payment.

> **Warning:** **The URL is the token.** The `plink_` id is 24 random characters and it is the only thing guarding the page, exactly like a checkout session. That is by design — the payer has no account and no key — but it does mean anyone with the URL can open it. A link does not expire; turn it off when you are done with it.

## Create one

```bash
curl https://api.secureprocessing.app/v1/payment_links \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
        "price": "price_3cD5eF7gH9jK1lM3nP5qR7sT",
        "quantity": 1,
        "after_completion": { "type": "redirect", "url": "https://example.bz/thanks" }
      }'
```

```ts
const link = await sp.paymentLinks.create({
  price: price.id,
  adjustable_quantity: { enabled: true, minimum: 1, maximum: 6 },
  collect_customer_name: true,
  custom_fields: [{ key: "boat_name", label: "Boat name", type: "text" }],
});

console.log(link.url);
// https://checkout.secureprocessing.app/pay/plink_7nQ9rS1tU3vW5xY7zA9bC1dE
```

Share `link.url`. That is the whole integration.

## What the customer sees

Your business name, the product, its first image and description, and the amount. Then their email — always, so we can send a receipt — plus a name, a phone number and up to three questions of your own if you asked for them. Pressing *Pay by card* takes them to a secure page hosted by the bank, so the card number never reaches your servers or ours.

Afterwards they land on our confirmation page, with an optional message from you, or we send them to a page of yours with `?session_id=` appended. A redirect has to be https: the payer arrives there with a session id in the URL.

The answers to your questions arrive on each payment’s metadata, alongside `payment_link` — so a fulfilment job reading the payment has everything it needs without a second call. Metadata values are strings, always, so the answers come as one JSON string under `payment_link_fields`:

```ts
const answers = JSON.parse(intent.metadata.payment_link_fields ?? "{}");
answers.boat_name; // "Kraken"

intent.metadata.payment_link;       // "plink_7nQ9rS1tU3vW5xY7zA9bC1dE"
intent.metadata.payment_link_name;  // present when you collected a name
intent.metadata.payment_link_phone; // present when you collected a phone number
```

## The amount comes from the price

> **Warning:** **Nothing a visitor sends can change what a link charges.** The amount is read from the price the link quotes, multiplied by a quantity the link allows. That is the entire security model of a URL that anyone can POST to.

The one exception is a price with a `custom_unit_amount` — a donation, a deposit, a tip. There the payer names a figure, and it is checked against the `minimum` and `maximum` you published before it is used. Every amount here is an integer in minor units: `6000` is $60.00.

Likewise the quantity. A link with `adjustable_quantity: { enabled: true, minimum: 1, maximum: 6 }` shows a picker; one without it charges for exactly what it says, and a request asking for anything else is refused rather than quietly obeyed. Both bounds are required when the picker is on, because the quantity multiplies the amount.

## A link’s price cannot be changed

`active`, the after-completion behaviour, the questions, the quantity picker, whether to collect a name or a phone number, and the metadata can all be updated. The `price` and the `quantity` cannot, and an attempt is refused with an explanation rather than ignored.

The reason is the same one that makes a price immutable, only sharper: the URL is already on a poster, in a QR code on a counter and in messages nobody can recall. The people who will pay it have been told what it costs, in a medium we cannot reach. To charge something else, create a new link and turn the old one off.

```ts
await sp.paymentLinks.update(link.id, { active: false });
```

Turning a link off is not deleting it. The page starts answering 404 — naming nothing about your business — but payments already taken stay attributed to it, and a customer who is on the bank’s page at that moment can still finish.

## What a link has taken

A link has no counter, because a counter is a second number to disagree with the first. Its payments are simply the payments that carry its id:

```bash
curl "https://api.secureprocessing.app/v1/payment_intents?payment_link=plink_7nQ9rS1tU3vW5xY7zA9bC1dE" \
  -H "Authorization: Bearer sk_test_..."
```

```ts
const paid = await sp.paymentIntents.list({
  payment_link: link.id,
  status: "succeeded",
});
```

`?created_via=payment_link` is the broader filter: every payment that came from any link, as opposed to `api`, `checkout`, `dashboard` or `invoice`.

## Events

| Event | When |
|---|---|
| payment_link.created | A link was created. Its URL is live from that moment. |
| payment_link.updated | A link was turned off, or had its fields or metadata changed. Never its price. |
| payment_intent.created | Somebody opened the link and started a payment. One per visitor who submits the form. |
| payment_intent.succeeded | The money is yours. Fulfil here, not on the redirect. |

> **Note:** Fulfil on `payment_intent.succeeded`, never on the customer arriving at your redirect. A browser that closes on the bank’s page still produces a paid payment, and a customer can open your thank-you URL without having paid at all.
