---
updatedAt: 2026-09-30T15:54:20.000Z
---

Fetch the complete documentation index at: https://docs.avvio.xyz/llms.txt. Use this file to discover all available pages before exploring further. Append .md to any documentation page URL to get its markdown version.

# Accept a checkout payment

Create a checkout link, send the buyer to it and fulfil the order when the
paid webhook arrives. [Checkout](/products/checkout/) covers link states and
the Stripe mapping.

> [!NOTE]
> **Before you start**
> Finish [Set up checkout](/checkout-setup/) for live payments. A test key
> needs none of it, because your sandbox organization can already sell by card.

```bash
export AVVIO_API_KEY=avvio_live_…
export AVVIO_ORG_ID=cmsx…
export AVVIO_BASE_URL=https://api.avvio.xyz/business/api/v1
```

Routes live under `/checkout/organizations/{orgId}`, shortened below to
`/checkout/…`. Reads work with a read-only key; writes need `write`, or get
`403 INSUFFICIENT_SCOPE`.

## Summary

1. Create a product: `POST /checkout/…/products`.
2. Create and publish a link: `POST /checkout/…/links`.
3. Send the buyer to `shareUrl`, and back to your `successUrl`.
4. Fulfil on the `checkout_payment.paid` webhook.
5. Rehearse every outcome in the sandbox.

## 1. Create a product

A product is a name and one fixed price in one currency, reused for as many
links as you need.

CLI:

```bash
avvio-payments product create --name "Consulting (60 min)" --currency USD --amount 150.00
```

curl:

```bash
IDEMPOTENCY_KEY=$(uuidgen)  # new key per call; reuse it only to retry this exact request
curl -s -X POST "$AVVIO_BASE_URL/checkout/organizations/$AVVIO_ORG_ID/products" \
  -H "x-api-key: $AVVIO_API_KEY" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -H "content-type: application/json" \
  -d '{
        "name": "Consulting (60 min)",
        "currency": "USD",
        "unitAmount": "150.00"
      }'
```

Node:

```js
const product = await avvio.createProduct({
  name: 'Consulting (60 min)',
  currency: 'USD',
  unitAmount: '150.00',
});
// → { id, name, currency, unitAmount, archivedAt: null, linkCount: 0, … }
```

Python:

```python
import os
import uuid
import requests

idempotency_key = str(uuid.uuid4())  # new key per call; reuse it only to retry this exact request

res = requests.post(
    f"{os.environ['AVVIO_BASE_URL']}/checkout/organizations/{os.environ['AVVIO_ORG_ID']}/products",
    headers={
        "x-api-key": os.environ["AVVIO_API_KEY"],
        "Idempotency-Key": idempotency_key,
    },
    json={
        "name": "Consulting (60 min)",
        "currency": "USD",
        "unitAmount": "150.00"
    },
)
print(res.status_code, res.json())
```

```json title="Response"
{
  "id": "2a7b8c9d-1e2f-4a5b-8c9d-0e1f2a3b4c5d",
  "name": "Consulting (60 min)",
  "description": null,
  "currency": "USD",
  "unitAmount": "150.00",
  "archivedAt": null,
  "linkCount": 0,
  "createdAt": "2026-09-12T09:55:00.000Z"
}
```

Keep `id`. A link copies the product's name and price when it is made, so
repricing the product leaves existing links unchanged. The description and
image are read live.

For a one-off amount, skip the product and send `currency` and `items` on the
link. Sending both `productId` and `items` is a `400`.

## 2. Create and publish a link

Create one link per order, with `publish: true`.

CLI:

```bash
avvio-payments checkout create --product 2a7b8c9d-1e2f-4a5b-8c9d-0e1f2a3b4c5d \
  --success-url https://example.com/thanks --cancel-url https://example.com/pricing \
  --ref order_1042 --meta orderId=1042
```

curl:

```bash
IDEMPOTENCY_KEY=$(uuidgen)  # new key per call; reuse it only to retry this exact request
curl -s -X POST "$AVVIO_BASE_URL/checkout/organizations/$AVVIO_ORG_ID/links" \
  -H "x-api-key: $AVVIO_API_KEY" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -H "content-type: application/json" \
  -d '{
        "productId": "2a7b8c9d-1e2f-4a5b-8c9d-0e1f2a3b4c5d",
        "successUrl": "https://example.com/thanks",
        "cancelUrl": "https://example.com/pricing",
        "clientReferenceId": "order_1042",
        "metadata": {
          "orderId": "1042"
        },
        "publish": true
      }'
```

Node:

```js
const link = await avvio.createCheckoutLink({
  productId: product.id,                    // 2a7b8c9d-1e2f-4a5b-8c9d-0e1f2a3b4c5d
  successUrl: 'https://example.com/thanks',
  cancelUrl: 'https://example.com/pricing',
  clientReferenceId: 'order_1042',          // your order id: the join key
  metadata: { orderId: '1042' },
  publish: true,                             // live now; the response carries shareUrl
});
res.redirect(link.shareUrl);
```

Python:

```python
import os
import uuid
import requests

idempotency_key = str(uuid.uuid4())  # new key per call; reuse it only to retry this exact request

res = requests.post(
    f"{os.environ['AVVIO_BASE_URL']}/checkout/organizations/{os.environ['AVVIO_ORG_ID']}/links",
    headers={
        "x-api-key": os.environ["AVVIO_API_KEY"],
        "Idempotency-Key": idempotency_key,
    },
    json={
        "productId": "2a7b8c9d-1e2f-4a5b-8c9d-0e1f2a3b4c5d",
        "successUrl": "https://example.com/thanks",
        "cancelUrl": "https://example.com/pricing",
        "clientReferenceId": "order_1042",
        "metadata": {
            "orderId": "1042"
        },
        "publish": True
    },
)
print(res.status_code, res.json())
```

```json title="Response"
{
  "id": "4f1c2a9e-8f7d-4c3b-9a2e-6b5d4c3f2a10",
  "slug": "7e2a9c4b1d0f",
  "status": "sent",
  "shareUrl": "https://business.avvio.xyz/i/7e2a9c4b1d0f",
  "fromName": "Northstar Consulting",
  "currency": "USD",
  "total": "150.00",
  "clientReferenceId": "order_1042",
  "received": null,
  "createdAt": "2026-09-12T09:58:00.000Z"
}
```

Store `slug` beside `id`: the success page receives only the slug.

| Field | What it does |
| --- | --- |
| `successUrl` | https. Where the buyer lands after a card payment. Bank and crypto payments never redirect |
| `cancelUrl` | https. A "Back to {your name}" link on the payer page |
| `clientReferenceId` | Your order id and join key. Letters, digits, `-` and `_`, up to 200. Echoed on every payment event |
| `metadata` | Up to 50 string keys (key ≤ 40 characters, value ≤ 500). Echoed on events, never shown to the buyer |
| `expiresAt` | ISO-8601 with a timezone, at most a year out. Omitted, the link runs until paused |
| `methods` | Omitted means card. `bank` and `crypto` need an account or address. `[]` makes a link nothing can pay |
| `fromName` | Defaults to your organization's name. Shown on the page. On the card statement, a `statementDescriptor` wins, then your organization's registered descriptor, then `fromName` |

Without `publish: true` you get a `draft` ([link states](/products/checkout/#link-states)).

> [!WARNING]
> Every `POST`, `PATCH` and `DELETE` under `/checkout` needs an
> `Idempotency-Key`, or it gets `400 IDEMPOTENCY_KEY_REQUIRED`. Retry under a
> new key and you create a second link. The Node SDK mints a key per call when
> you pass none ([Idempotency](/idempotency/)).

### When publishing is refused

A card link cannot publish while card onboarding is unfinished. The call still
answers `201` and keeps the draft, with a `publishError`:

```jsonc
{
  "id": "4f1c2a9e-…",
  "status": "draft",                        // not "sent"
  "shareUrl": "https://…/i/7e2a9c4b1d0f",   // answers 404 while a draft
  "publishError": {
    "status": 400,
    "type": "BAD_REQUEST",
    "message": "This business is not set up to accept cards yet. Finish card onboarding, then publish the link."
  }
}
```

Branch on `status !== "sent"`, then read `publishError.type`: `BAD_REQUEST`,
`CONFLICT`, `NOT_FOUND`, `FORBIDDEN`, `INTERNAL`, or a specific code such as
`LINK_EXPIRED`. Fix the cause and call `POST /links/{linkId}/publish`, or
`DELETE` the draft. The answer is a `201` because only successes are stored
against an idempotency key, and an error would let a retry create a second
draft.

A card link in a currency the processor cannot price is different: the create
(or update) itself fails with `400`, and no draft is stored. `publish` and
`DELETE` of a draft have no SDK method yet; call them over HTTP.

## 3. Send the buyer, and take them back

Redirect the buyer to `shareUrl`. After a card payment they return to your
`successUrl`:

```
https://example.com/thanks?avvio_link=7e2a9c4b1d0f&client_reference_id=order_1042
```

`avvio_link` is the link's slug; `client_reference_id` appears when known.

> [!CAUTION]
> Anyone can type that URL. If you fulfil on the redirect, you ship unpaid
> orders. Use it to choose which order to show, and confirm payment from the
> webhook or a read of the link's payments.

CLI:

```bash
avvio-payments checkout payments <linkId>
```

curl:

```bash
curl -s "$AVVIO_BASE_URL/checkout/organizations/$AVVIO_ORG_ID/links/$linkId/payments" \
  -H "x-api-key: $AVVIO_API_KEY"
```

Node:

```js
// One link per order: the link is the join, not the payment row's clientReferenceId
const { items } = await avvio.listCheckoutPayments(linkId);
const paid = items.find((p) => p.status === 'paid');
// amountBase "15000" at decimals 2 is 150.00
```

Python:

```python
import os
import requests

linkId = "<linkId>"

res = requests.get(
    f"{os.environ['AVVIO_BASE_URL']}/checkout/organizations/{os.environ['AVVIO_ORG_ID']}/links/{linkId}/payments",
    headers={
        "x-api-key": os.environ["AVVIO_API_KEY"],
    },
)
print(res.status_code, res.json())
```

This read returns base units with `decimals` (`"15000"` at `decimals: 2` is
150.00); the webhook uses decimal strings. A payment row's `clientReferenceId`
is the buyer visit's (`?client_reference_id=`), on the card rail only, so it is
usually `null` when you set `clientReferenceId` on the link. Create one link per
order and look for a `paid` row on that link, as above.

## 4. Fulfil on `checkout_payment.paid`

| Event | Meaning |
| --- | --- |
| `checkout_payment.paid` | The acceptor has the money. Fulfil on this, and only this |
| `checkout_payment.failed` | A card attempt failed. Nothing to fulfil |
| `checkout_payment.partially_refunded` | Part went back (`refund` says which). Still `paid` |
| `checkout_payment.refunded` | Refunded in full. Reverse the fulfilment |
| `checkout_payment.reversed` | A chargeback. Reverse the fulfilment |

Name these types when you register the endpoint. An empty `events` list
receives every payout type but none of these, so a payout-only receiver never
`4xx`s its way to auto-disable.

`data` is a `WebhookCheckoutPayment` ([every event](/coverage/webhook-events/)):

```jsonc
{
  "id": "cmf9x7q4r0011q8b7w2e4t6yu",   // equals svix-id. Dedupe on this
  "type": "checkout_payment.paid",
  "livemode": true,
  "data": {
    "paymentId": "cmf9x1b2c0003q8b7h6j8k0lm",
    "linkId": "4f1c2a9e-8f7d-4c3b-9a2e-6b5d4c3f2a10",
    "clientReferenceId": "order_1042",   // your join key
    "metadata": { "orderId": "1042" },
    "status": "paid",
    "failureCode": null,
    "kind": "card",                      // card | bank | crypto | cashapp. Never the processor
    "amount": "150.00",                  // decimal string in `currency`
    "currency": "USD",
    "fee": "4.35",                       // "0.00" when none was stated
    "net": "145.65",                     // null when the processor did not say
    "linkExpiredAt": null                // the link's expiresAt when this landed AFTER it
    // …linkSlug, productId, refunded, paidAt, createdAt
  }
}
```

```js
const { verifyWebhook } = require('@avvio/payments');

app.post('/hooks/avvio', express.raw({ type: '*/*' }), async (req, res) => {
  let event;
  try {
    event = verifyWebhook({ body: req.body, headers: req.headers, secret: process.env.AVVIO_WEBHOOK_SECRET });
  } catch {
    return res.sendStatus(400);                       // 1. verify over the raw bytes
  }
  res.sendStatus(200);                                // ack first, work after

  if (event.type !== 'checkout_payment.paid') return handleOther(event);
  if (await seen(event.id)) return;                   // 2. dedupe on the event id
  const { clientReferenceId, amount, currency } = event.data;
  const order = await orders.find(clientReferenceId); // 3. join on your reference
  if (!order) return alert('paid, no order', event);
  if (order.total !== amount || order.currency !== currency) {
    return alert('amount mismatch', event);           // 4. compare amount + currency
  }
  await fulfil(order, event.data.paymentId);
});
```

The event's `clientReferenceId` is the buyer visit's `?client_reference_id=`
when the card page carried one, otherwise the link's. `linkExpiredAt` is set
when the payment landed after the link's `expiresAt`, such as a slow wire. The
payment is `paid` either way, so decide whether to honour or refund it.

### Recovering missed events

A delivery you never received looks the same as one that never fired, so the
event feed is the record. Read
`GET /payments/organizations/{orgId}/events?type=checkout_payment.paid&since=`
and pass `nextSince` back as `since`. A row appears about two seconds after the
write ([Reconcile your ledger](/reconciliation/)).

## 5. Test it in the sandbox

With an `avvio_test_…` key, pay the link yourself:

```bash
IDEMPOTENCY_KEY=$(uuidgen)  # new key per call; reuse it only to retry this exact request
curl -s -X POST \
  "$AVVIO_BASE_URL/checkout/organizations/$AVVIO_ORG_ID/links/$LINK_ID/payments/simulate" \
  -H "x-api-key: $AVVIO_API_KEY" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -H 'Content-Type: application/json' -d '{"clientReferenceId":"order_1042"}'
```

The cents of the total choose the outcome: `.01` declines, `.02` pays after
20 seconds, `.03` refunds, `.04` charges back, `.05` declines for insufficient
funds, `.06` pays and is then half refunded, and any other ending pays at once. Every ending and its
events are in [Environments & sandbox](/environments/#checkout-outcomes-by-amount-suffix).
To force an outcome, call `POST /checkout/…/payments/{paymentId}/simulate` with
`{"action":"chargeback"}`. Events arrive signed, with `livemode: false`.

> [!WARNING]
> Run `.04` before you go live. A chargeback takes back money your ledger
> already booked, and this is the only way to see whether it survives.

Before launch, run one small live payment. A real card exercises the hosted
page, 3-D Secure and the wallet buttons, which the simulator cannot. The
processing fee on a refunded live payment is not returned.

## Check the outcome

The order is paid once your handler has processed `checkout_payment.paid` for
its `clientReferenceId`, or `GET /links/{linkId}/payments` shows a `paid` row.
To reconcile on a schedule, page that read for your live links and compare
`status` and `refundedBase` (`null` when nothing was refunded). Next, handle [refunds](/refunds/).
