# Payment links

Source: https://caravelpay.app/docs/payment-links.html · Caravel Pay docs · Last updated 2026-11-09

A payment link is a URL you can send anywhere: a chat, an email, a bio or a QR code. Each time someone opens it, they get a fresh hosted checkout and pay by card, Apple Pay, Google Pay or crypto. You don't need a website or API keys, just a Polygon wallet.

## Two ways to make a link

|  | Without API keys | With API keys |
|---|---|---|
| How | Send `payout_address` with no signature, or use the [link page](https://caravelpay.app/link/) | Sign the request like any other call |
| Paid to | The wallet in `payout_address` | Your merchant's payout wallet |
| Webhooks | None. Payouts arrive in your wallet automatically | Yes, to your `callback_url` |
| See the checkouts | In your wallet's history | `GET /v1/checkouts` and `GET /v1/payment-links/{id}` |
| Turn a link off | Not possible: make a single-use link if you need it to close | `POST /v1/payment-links/{id}/deactivate` |

A link made without keys returns no credentials, so nobody, including you, can read its checkouts through the API. That is what makes it safe to create one from a public page.

## Create a payment link

`POST/v1/payment-links`

| Parameter | Description |
|---|---|
| `payout_address` · string · without keys | Your Polygon wallet, `0x` and 40 hex characters. Use a plain wallet you control (MetaMask, Rabby, a hardware wallet), not an exchange deposit address or a smart-contract wallet. Only for links made without keys; a signed request that sends it gets `400`. |
| `amount` · string or null · required | The price with exactly two decimals, like `"25.00"`, from `1.00` up to your per-checkout limit (`1000.00` for new merchants). Send `null` to [let the buyer enter the amount](https://caravelpay.app/docs/payment-links.html#open-amount). |
| `min_amount`, `max_amount` · string | Only with `amount: null`. The range the buyer may enter. Default `1.00` to your per-checkout limit. |
| `currency` · string · required | Always `"USD"`. |
| `description` · string | Optional plain text, at most 80 characters, shown to the buyer as *Note from the seller*. No links, web addresses or HTML. |
| `reusable` · boolean | Default `true`: anyone may pay through the link, any number of times. `false`: the link closes after one successful payment. |
| `callback_url` · string | Signed links only. HTTPS on one of your [merchant origins](https://caravelpay.app/docs/merchant.html). Webhooks for this link's checkouts go here. Leave it out for no webhooks. |
| `return_url` · string | Signed links only. Where the buyer goes after paying, on your merchant origins. Default: a neutral Caravel Pay "Payment received" page. |

### Without API keys

No headers beyond `Content-Type`. The answer is the link's id and URL, nothing else.

*POST /v1/payment-links (no keys) · curl*

```sh
curl https://api.caravelpay.app/v1/payment-links \
  -H "Content-Type: application/json" \
  --data-binary '{
    "payout_address": "0x52908400098527886E0F7030069857D2E4169EE7",
    "amount": "25.00",
    "currency": "USD",
    "description": "Logo design, 2 rounds"
  }'
```

*POST /v1/payment-links (no keys) · Node.js*

```js
// Node.js 18+ (built-in fetch). No keys needed.
const res = await fetch("https://api.caravelpay.app/v1/payment-links", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    payout_address: "0x52908400098527886E0F7030069857D2E4169EE7",
    amount: "25.00",
    currency: "USD",
    description: "Logo design, 2 rounds",
  }),
});
const link = await res.json();
if (!res.ok) throw new Error(`${link.error.code}: ${link.error.message}`);
console.log(link.url); // share this
```

*POST /v1/payment-links (no keys) · PHP*

```php
<?php
// PHP 7.4+ with the curl extension. No keys needed.
$ch = curl_init('https://api.caravelpay.app/v1/payment-links');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 15,
    CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
    CURLOPT_POSTFIELDS     => json_encode([
        'payout_address' => '0x52908400098527886E0F7030069857D2E4169EE7',
        'amount'         => '25.00',
        'currency'       => 'USD',
        'description'    => 'Logo design, 2 rounds',
    ], JSON_UNESCAPED_SLASHES),
]);
$link = json_decode((string) curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) !== 201) {
    throw new RuntimeException(($link['error']['code'] ?? 'error') . ': ' . ($link['error']['message'] ?? ''));
}
echo $link['url'], "\n"; // share this
```

*POST /v1/payment-links (no keys) · Python*

```py
# Python 3.8+ with requests (pip install requests). No keys needed.
import requests

res = requests.post("https://api.caravelpay.app/v1/payment-links", timeout=15, json={
    "payout_address": "0x52908400098527886E0F7030069857D2E4169EE7",
    "amount": "25.00",
    "currency": "USD",
    "description": "Logo design, 2 rounds",
})
link = res.json()
if res.status_code != 201:
    raise RuntimeError(f"{link['error']['code']}: {link['error']['message']}")
print(link["url"])  # share this
```

*201 Created*

```json
{
  "id": "lnk_K7Q2ZX4PRBM5TW3NHJ6D",
  "url": "https://pay.caravelpay.app/l/lnk_K7Q2ZX4PRBM5TW3NHJ6D"
}
```

Links for the same wallet share one behind-the-scenes merchant, with the same [2% Caravel fee](https://caravelpay.app/docs/payouts.html) and limits as any new merchant. The wallet is checked the same way as at [connect](https://caravelpay.app/docs/connect.html#wallet): sanctioned addresses, contracts and Caravel Pay's own addresses are refused with `400 invalid_request`, `param: "payout_address"`. Each IP address may create 30 links an hour.

### With API keys

Signed like any request. The link belongs to your merchant: its checkouts show up in `GET /v1/checkouts`, and webhooks go to its `callback_url`.

*POST /v1/payment-links (signed) · curl*

```sh
BODY='{"amount":"49.00","currency":"USD","description":"Workshop seat","reusable":false,"callback_url":"https://shop.example/webhooks/caravel"}'
TS=$(date +%s)
SIG=$(printf '%s\n%s\n%s\n%s' "$TS" POST /v1/payment-links "$BODY" \
  | openssl dgst -sha256 -hmac "$CARAVEL_SECRET" | awk '{print $NF}')

curl https://api.caravelpay.app/v1/payment-links \
  -H "Content-Type: application/json" \
  -H "Caravel-Merchant: $CARAVEL_MERCHANT" \
  -H "Caravel-Timestamp: $TS" \
  -H "Caravel-Signature: $SIG" \
  --data-binary "$BODY"
```

*POST /v1/payment-links (signed) · Node.js*

```js
import { caravel } from "./caravel.mjs"; // signing helper from the Quickstart

const link = await caravel("POST", "/v1/payment-links", {
  amount: "49.00",
  currency: "USD",
  description: "Workshop seat",
  reusable: false,
  callback_url: "https://shop.example/webhooks/caravel",
});
```

*POST /v1/payment-links (signed) · PHP*

```php
require __DIR__ . '/caravel.php'; // signing helper from the Quickstart

$link = caravel('POST', '/v1/payment-links', [
    'amount'       => '49.00',
    'currency'     => 'USD',
    'description'  => 'Workshop seat',
    'reusable'     => false,
    'callback_url' => 'https://shop.example/webhooks/caravel',
]);
```

*POST /v1/payment-links (signed) · Python*

```py
from caravel import caravel  # signing helper from the Quickstart

link = caravel("POST", "/v1/payment-links", {
    "amount": "49.00",
    "currency": "USD",
    "description": "Workshop seat",
    "reusable": False,
    "callback_url": "https://shop.example/webhooks/caravel",
})
```

*201 Created*

```json
{
  "id": "lnk_M5TW3NHJ6DK7Q2ZX4PRB",
  "url": "https://pay.caravelpay.app/l/lnk_M5TW3NHJ6DK7Q2ZX4PRB",
  "status": "active",
  "amount": "49.00",
  "min_amount": null,
  "max_amount": null,
  "currency": "USD",
  "description": "Workshop seat",
  "reusable": false,
  "return_url": null,
  "callback_url": "https://shop.example/webhooks/caravel",
  "payments_count": 0,
  "checkouts_count": 0,
  "created_at": "2026-11-09T21:04:12Z",
  "deactivated_at": null
}
```

## Let the buyer choose the amount

Send `"amount": null` for tips, donations or invoices of varying size. The buyer first sees a short "Enter the amount to pay" step, then the normal checkout. Set `min_amount` and `max_amount` to narrow the range.

*Request body*

```json
{
  "payout_address": "0x52908400098527886E0F7030069857D2E4169EE7",
  "amount": null,
  "min_amount": "5.00",
  "max_amount": "250.00",
  "currency": "USD",
  "description": "Tips for the crew"
}
```

## The payment link object

Returned by the signed calls. Links made without keys return only `id` and `url`.

| Field | Description |
|---|---|
| `id` · string | `lnk_` and 20 characters. |
| `url` · string | The link to share. |
| `status` · string | `active`, `completed` (a single-use link that has been paid) or `deactivated`. |
| `amount` · string or null | The fixed price, or `null` when the buyer enters it. |
| `min_amount`, `max_amount` · string or null | The buyer's range for an open amount; `null` for a fixed price. |
| `currency` · string | `"USD"`. |
| `description` · string or null | The note shown to the buyer. |
| `reusable` · boolean | Whether more than one payment may be made. |
| `return_url`, `callback_url` · string or null | As sent. |
| `payments_count` · integer | Checkouts from this link that are [paid](https://caravelpay.app/docs/statuses.html). |
| `checkouts_count` · integer | Checkouts opened from this link, paid or not. |
| `created_at`, `deactivated_at` · string | ISO 8601 UTC. `deactivated_at` is `null` until you deactivate the link. |

## Retrieve a payment link

`GET/v1/payment-links/{id}`

Signed, with an empty body. A link that isn't yours answers `404 not_found`, exactly like one that doesn't exist.

*GET /v1/payment-links/{id} · curl*

```sh
LINK=lnk_M5TW3NHJ6DK7Q2ZX4PRB
TS=$(date +%s)
SIG=$(printf '%s\n%s\n%s\n%s' "$TS" GET "/v1/payment-links/$LINK" "" \
  | openssl dgst -sha256 -hmac "$CARAVEL_SECRET" | awk '{print $NF}')

curl "https://api.caravelpay.app/v1/payment-links/$LINK" \
  -H "Caravel-Merchant: $CARAVEL_MERCHANT" \
  -H "Caravel-Timestamp: $TS" \
  -H "Caravel-Signature: $SIG"
```

*GET /v1/payment-links/{id} · Node.js*

```js
const link = await caravel("GET", "/v1/payment-links/lnk_M5TW3NHJ6DK7Q2ZX4PRB");
console.log(link.status, link.payments_count);
```

*GET /v1/payment-links/{id} · PHP*

```php
$link = caravel('GET', '/v1/payment-links/lnk_M5TW3NHJ6DK7Q2ZX4PRB');
echo $link['status'], ' ', $link['payments_count'], "\n";
```

*GET /v1/payment-links/{id} · Python*

```py
link = caravel("GET", "/v1/payment-links/lnk_M5TW3NHJ6DK7Q2ZX4PRB")
print(link["status"], link["payments_count"])
```

## Deactivate a payment link

`POST/v1/payment-links/{id}/deactivate`

Signed, with an empty body or `{}`. The link stops opening new checkouts at once and buyers see "This payment link is no longer active". Checkouts that were already open can still be paid, and those payouts reach you as usual. Calling it again changes nothing. There is no way to turn a link back on: create a new one.

*POST /v1/payment-links/{id}/deactivate · curl*

```sh
LINK=lnk_M5TW3NHJ6DK7Q2ZX4PRB
TS=$(date +%s)
SIG=$(printf '%s\n%s\n%s\n%s' "$TS" POST "/v1/payment-links/$LINK/deactivate" "" \
  | openssl dgst -sha256 -hmac "$CARAVEL_SECRET" | awk '{print $NF}')

curl -X POST "https://api.caravelpay.app/v1/payment-links/$LINK/deactivate" \
  -H "Caravel-Merchant: $CARAVEL_MERCHANT" \
  -H "Caravel-Timestamp: $TS" \
  -H "Caravel-Signature: $SIG"
```

*POST /v1/payment-links/{id}/deactivate · Node.js*

```js
const link = await caravel("POST", "/v1/payment-links/lnk_M5TW3NHJ6DK7Q2ZX4PRB/deactivate");
```

*POST /v1/payment-links/{id}/deactivate · PHP*

```php
$link = caravel('POST', '/v1/payment-links/lnk_M5TW3NHJ6DK7Q2ZX4PRB/deactivate');
```

*POST /v1/payment-links/{id}/deactivate · Python*

```py
link = caravel("POST", "/v1/payment-links/lnk_M5TW3NHJ6DK7Q2ZX4PRB/deactivate")
```

*200 OK (excerpt)*

```json
{
  "id": "lnk_M5TW3NHJ6DK7Q2ZX4PRB",
  "status": "deactivated",
  "deactivated_at": "2026-11-10T09:30:00Z"
}
```

## What the buyer sees

1. They open the link. A fixed price continues straight to the checkout; an open amount asks for the amount first.
2. The hosted checkout shows the amount and your note, never a store name or logo, and lets them pay by card, Apple Pay, Google Pay or crypto. Any card provider fee is shown before they pay.
3. After paying they land on a neutral "Payment received" page (or your `return_url` for a signed link).

Every opening creates a new checkout with order id `lnk_…-1`, `lnk_…-2` and so on. A buyer who opens the link again in the same browser session gets their unpaid checkout back instead of a new one. Each checkout expires 60 minutes after it opens, like any other.

## Webhooks and reconciliation

For a signed link with a `callback_url`, each checkout sends the usual [webhooks](https://caravelpay.app/docs/webhooks.html); the checkout's `order_id` starts with the link id. Links made without keys send no webhooks: watch your wallet, where each payout arrives automatically.

> **Single-use links:** the link closes after its first paid checkout, and while a payment is confirming nobody else can start one. A buyer who opened the link earlier can still finish their checkout, and that payment reaches you as usual.

## Fees

The same as any payment: you receive the amount minus the flat [2% Caravel Pay fee](https://caravelpay.app/docs/payouts.html), in your wallet, automatically once the payment confirms. The buyer pays their card provider's fee, shown to them before they pay.

## Errors

| Code | When |
|---|---|
| `invalid_amount` 400 | `amount`, `min_amount` or `max_amount` isn't a two-decimal string in range, or `min_amount` is above `max_amount`. |
| `invalid_request` 400 | A missing or unknown field, a bad `payout_address` or wallet, or a `description` with a link, HTML or more than 80 characters. `param` names the field. |
| `invalid_url` 400 | `callback_url` or `return_url` isn't HTTPS on your merchant origins. |
| `unsupported_currency` 400 | `currency` isn't `"USD"`. |
| `merchant_paused` 403 | Payments to this wallet or merchant are paused. |
| `not_found` 404 | No link with that id belongs to your merchant. |
| `limit_exceeded` 409 | The wallet already receives payouts for the most merchants allowed. |
| `rate_limited` 429 | Too many links from your address. Wait for `Retry-After`. |

All codes and the retry rules are on [Errors](https://caravelpay.app/docs/errors.html).
