# Caravel Pay API: reference for AI coding agents

Use this file as the single source of truth when you write code that integrates Caravel Pay. It
covers every endpoint, object, status, webhook rule, error code and the exact signing algorithm,
with test vectors. Human docs: https://caravelpay.app/docs/ · OpenAPI 3.1: `openapi.yaml` · Postman:
`caravel-pay-api.postman_collection.json`.

## 1. What Caravel Pay is

- A hosted checkout. The buyer pays by card, Apple Pay or Google Pay (or directly in USDC, USDC.e or
  POL on Polygon). The merchant receives the payment on Polygon, minus a flat 2%, in their own wallet.
- Payouts are automatic and per checkout, in the token the buyer paid with, never swapped: card
  payments and USDC arrive as USDC, USDC.e as USDC.e, POL as POL.
- **No accounts, logins, dashboards or test mode.** `POST /v1/connect` returns a merchant id and a
  secret; those credentials are the merchant. Test with a small real payment.
- Prices are in **USD only**, as strings with exactly two decimals.

| Fact | Value |
|---|---|
| API base URL | `https://api.caravelpay.app` (placeholder host until the final domain is announced; make it configurable) |
| Hosted checkout | `checkout_url` from the API, e.g. `https://pay.caravelpay.app/c/chk_8Qe2TnV4kR` (never build it yourself) |
| Auth | HMAC-SHA256 request signing with `Caravel-Merchant`, `Caravel-Timestamp`, `Caravel-Signature` |
| Content type | JSON in, JSON out (`Content-Type: application/json`), request bodies at most 16 KiB |
| Merchant id | `mch_` + 10 characters, e.g. `mch_7Q2KZX4PRB` |
| Checkout id | `chk_…`, e.g. `chk_8Qe2TnV4kR` |
| Event id | `evt_…`, e.g. `evt_3Lk9Wq2Zr7Yp` |
| Checkout lifetime | 60 minutes |
| Fee | 2% of each payment, taken on-chain; it also covers payout gas |

## 2. Rules an integration MUST follow

1. **Keep the secret on the server.** Never put it in browser or mobile code, logs, URLs, error
   messages or version control. Load it from configuration or a secret store.
2. **Create checkouts server-side** with your own `order_id`, the order total as `amount`, and
   `return_url`, `cancel_url` and `callback_url` that are HTTPS URLs on the merchant's `site_url` (or its
   `www.` twin).
3. **Redirect the buyer to `checkout_url`** (a full-page redirect; use the URL exactly as returned).
4. **Never treat the return to `return_url` as payment.** Only a verified webhook, or a signed
   `GET /v1/checkouts/{id}` that answers `"status": "paid"`, means paid.
5. **Verify every webhook** before reading it: signature over the **raw body bytes** and the **path
   plus query string of your callback URL**, timestamp within 300 seconds, constant-time comparison,
   secret chosen by the `Caravel-Merchant` header.
6. **After verifying, re-fetch the checkout** with `GET /v1/checkouts/{id}` and act on that response's
   `status`; webhooks can arrive late, twice or out of order.
7. **Be idempotent.** Store processed event ids; a duplicate gets `200` without doing the work again.
8. **Answer webhooks with 2xx within 10 seconds**, after storing the event. Anything else is retried.
9. **Fulfil only on `paid`.** `expired` can still become `paid` (a payment sent before expiry that
   confirmed late): complete the order even if you already cancelled it.
10. **Retries are safe with the same `order_id`.** It returns the same checkout. After a checkout
    expires, use a new `order_id` (e.g. `1042-2`) and keep the old checkout id linked to the order.
11. **Keep old credentials after reconnecting.** Every `POST /v1/connect` creates a new merchant.
    Checkouts made with the old credentials still finish on the old merchant and their webhooks are
    signed with the old secret, so keep accepting it for at least 24 hours after its last checkout.
12. **Make the API base URL configurable** (staging may use `http://127.0.0.1:<port>`; production is HTTPS).

## 3. Request signing

Every endpoint except `POST /v1/connect` and `POST /v1/reports` needs three headers (creating a
payment link may be signed or unsigned, see §4.6):

| Header | Value |
|---|---|
| `Caravel-Merchant` | your merchant id |
| `Caravel-Timestamp` | current Unix time in seconds (10 digits), within 300 s of the server clock |
| `Caravel-Signature` | lowercase hex HMAC-SHA256 of the string to sign, keyed with the secret used as a plain ASCII string (do not hex-decode it) |

String to sign: four values joined by a single `\n`, no trailing newline:

```
{timestamp}\n{METHOD}\n{path}\n{rawBody}
```

- `METHOD`: uppercase, `GET` or `POST`.
- `path`: the request path **plus the query string if there is one**, without scheme or host:
  `/v1/checkouts`, `/v1/checkouts/chk_8Qe2TnV4kR`, `/v1/checkouts?limit=20&cursor=…`. Never the full URL.
- `rawBody`: the exact bytes you send (serialize JSON once, sign that string, send that string). Empty
  string for `GET`.

Webhooks are signed the same way with `METHOD` = `POST` and `path` = the path and query of the
`callback_url` you gave the checkout (e.g. `/webhooks/caravel?shop=main`). Behind a proxy that rewrites
paths, verify against the original callback path.

<!-- test:node-sign -->
```js
// Node.js 18+: sign a request; verify a webhook.
const crypto = require('node:crypto');

function sign(secret, timestamp, method, path, rawBody) {
  return crypto.createHmac('sha256', Buffer.from(secret, 'utf8'))
    .update(`${timestamp}\n${method.toUpperCase()}\n${path}\n${rawBody}`, 'utf8')
    .digest('hex');
}

// headers: lowercase names. secrets: { [merchantId]: secret } (current and replaced credentials).
// callbackPath: path + query of the callback_url you sent. now: Unix seconds.
function verifyWebhook(rawBody, headers, secrets, callbackPath, now) {
  const merchant = headers['caravel-merchant'] || '';
  const timestamp = headers['caravel-timestamp'] || '';
  const signature = headers['caravel-signature'] || '';
  const secret = Object.prototype.hasOwnProperty.call(secrets, merchant) ? secrets[merchant] : null;
  if (!secret) return { ok: false, reason: 'unknown_merchant' };
  if (!/^\d{1,12}$/.test(timestamp) || Math.abs(now - Number(timestamp)) > 300) return { ok: false, reason: 'timestamp_expired' };
  if (!/^[0-9a-f]{64}$/.test(signature)) return { ok: false, reason: 'invalid_signature' };
  const expected = Buffer.from(sign(secret, timestamp, 'POST', callbackPath, rawBody), 'utf8');
  if (!crypto.timingSafeEqual(expected, Buffer.from(signature, 'utf8'))) return { ok: false, reason: 'invalid_signature' };
  return { ok: true, merchant, event: JSON.parse(rawBody) };
}
```

<!-- test:php-sign -->
```php
<?php
// PHP 7.4+: sign a request; verify a webhook.
function caravel_sign(string $secret, string $timestamp, string $method, string $path, string $rawBody): string
{
    return hash_hmac('sha256', $timestamp . "\n" . strtoupper($method) . "\n" . $path . "\n" . $rawBody, $secret);
}

/** @param array<string,string> $secrets merchant id => secret. Returns the decoded event or null. */
function caravel_verify_webhook(string $rawBody, string $merchant, string $timestamp, string $signature, array $secrets, string $callbackPath, int $now): ?array
{
    $secret = $secrets[$merchant] ?? null;
    if ($secret === null || !preg_match('/^[0-9]{1,12}$/D', $timestamp) || abs($now - (int) $timestamp) > 300 || !preg_match('/^[0-9a-f]{64}$/D', $signature)) {
        return null;
    }
    if (!hash_equals(caravel_sign($secret, $timestamp, 'POST', $callbackPath, $rawBody), $signature)) {
        return null;
    }
    $event = json_decode($rawBody, true);
    return is_array($event) ? $event : null;
}
// In a handler: $rawBody = file_get_contents('php://input'); headers from $_SERVER['HTTP_CARAVEL_MERCHANT'] etc.
```

<!-- test:python-sign -->
```python
# Python 3.8+: sign a request; verify a webhook.
import hashlib
import hmac
import json
import re

def sign(secret: str, timestamp: str, method: str, path: str, raw_body: bytes) -> str:
    message = f"{timestamp}\n{method.upper()}\n{path}\n".encode("utf-8") + raw_body
    return hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()

def verify_webhook(raw_body: bytes, headers: dict, secrets: dict, callback_path: str, now: int):
    """headers: lowercase names. Returns the event dict, or None."""
    secret = secrets.get(headers.get("caravel-merchant", ""))
    timestamp = headers.get("caravel-timestamp", "")
    signature = headers.get("caravel-signature", "")
    if not secret or not re.fullmatch(r"\d{1,12}", timestamp) or abs(now - int(timestamp)) > 300:
        return None
    if not re.fullmatch(r"[0-9a-f]{64}", signature):
        return None
    if not hmac.compare_digest(sign(secret, timestamp, "POST", callback_path, raw_body), signature):
        return None
    return json.loads(raw_body)
```

curl (bash) for a signed request:

```sh
TS=$(date +%s)
BODY='{"order_id":"1042","amount":"136.41","currency":"USD","return_url":"https://shop.example/thanks","cancel_url":"https://shop.example/cart","callback_url":"https://shop.example/webhooks/caravel"}'
SIG=$(printf '%s\n%s\n%s\n%s' "$TS" POST /v1/checkouts "$BODY" | openssl dgst -sha256 -hmac "$CARAVEL_SECRET" -hex | sed 's/^.* //')
curl -sS https://api.caravelpay.app/v1/checkouts -H 'Content-Type: application/json' \
  -H "Caravel-Merchant: $CARAVEL_MERCHANT_ID" -H "Caravel-Timestamp: $TS" -H "Caravel-Signature: $SIG" --data-raw "$BODY"
```

## 4. Endpoints

### 4.1 `POST /v1/connect`: get credentials (unsigned)

Request: `{site_url, payout_address, platform, email?, plugin_version?}`. Unknown fields are refused.

| Field | Rules |
|---|---|
| `site_url` | the merchant's HTTPS site address (`https://shop.example`; a path is allowed for sites in a subfolder). Return, cancel and callback URLs must be on it or its `www.` twin. |
| `payout_address` | Polygon address `0x` + 40 hex. An ordinary wallet the merchant controls; contracts, token addresses, exchange deposit contracts and sanctioned addresses are refused. |
| `platform` | `"api"` (or `"woocommerce"` for that plugin) |
| `email` | optional contact for operational notices; never a login |
| `plugin_version` | optional, 1–32 printable characters (modules send `<platform>-<version>`, e.g. `prestashop-1.0.0`) |

`201` → `{merchant_id, secret, status, fee_rate}` with `status` `active` (or `pending_review` when manual
review is on) and `fee_rate` `"2%"`. **Every call creates a new merchant**; the secret is shown only in
this response. Errors: `400 invalid_request` (`param` = the field), `400 invalid_url` (`site_url`),
`409 limit_exceeded` (the wallet already serves the maximum number of merchants), `429 rate_limited`
(10 per hour per IP), `503 service_unavailable` (new merchants briefly paused; honour `Retry-After`).

### 4.2 `GET /v1/merchant`

`200` → `{id, site_url, origins[], payout_address, status, fee_rate}`; `status` is `active`,
`pending_review` or `paused` (paused may add `paused_reason`). Use it to test credentials.

### 4.3 `POST /v1/checkouts`: create a checkout

Body (all strings; unknown fields refused):

| Field | Required | Rules |
|---|---|---|
| `order_id` | yes | 1–128 characters, no control characters, unique per merchant |
| `amount` | yes | exactly two decimals, `"0.01"` to `"1000000.00"` (per-merchant limits may be lower) |
| `currency` | yes | `"USD"` |
| `customer_email` | no | pre-fills the buyer's email |
| `return_url` | yes | HTTPS on the merchant's origins; where the buyer goes after paying |
| `cancel_url` | yes | HTTPS on the merchant's origins |
| `callback_url` | yes | HTTPS on the merchant's origins; receives this checkout's webhooks |

Optional header `Idempotency-Key` (8–128 printable ASCII); without it `order_id` is the idempotency key.

- `201` → `{id, checkout_url, status, amount, currency, expires_at}` (new checkout, `awaiting_payment`).
- `200` → the existing checkout when the same `order_id` was sent with the same details (safe retry).
- `409 order_id_conflict` when the `order_id` was used with different details (amount, currency, email
  or any URL): use a new `order_id`.
- Also `400 invalid_request | invalid_amount | unsupported_currency | invalid_url`,
  `403 merchant_paused | merchant_pending_review`, `409 limit_exceeded`, `429`, `500`, `503`.

### 4.4 `GET /v1/checkouts/{id}`

`200` → the full checkout (§5.1). A checkout of another merchant answers `404 not_found`.

### 4.5 `GET /v1/checkouts?limit=&cursor=`

Newest first. `limit` 1–100 (default 20), `cursor` = `next_cursor` of the previous page (`null` on the
last page). `200` → `{data: [checkout…], next_cursor}`. Sign the path **with** the query string.

### 4.6 Payment links

- `POST /v1/payment-links`. **Unsigned** (no API keys): body `{payout_address, amount, currency, description?, min_amount?, max_amount?, reusable?}` → `201 {id, url}`; such links send no webhooks (30 per hour per IP).
  **Signed**: body without `payout_address`, optionally `return_url`, `callback_url` → `201` full link object; its checkouts appear in `GET /v1/checkouts` and send webhooks to `callback_url`.
  `amount` `null` lets the buyer enter it within `min_amount`–`max_amount` (default 1.00 to the per-checkout limit; 1000.00 for new merchants). `reusable: false` closes the link after one paid checkout. `description` is plain text, at most 80 characters, no links.
- `GET /v1/payment-links/{id}` (signed) → `{id, url, status (active|completed|deactivated), amount, min_amount, max_amount, currency, description, reusable, return_url, callback_url, payments_count, checkouts_count, created_at, deactivated_at}`.
- `POST /v1/payment-links/{id}/deactivate` (signed, body `{}`) → the link, now `deactivated`; open checkouts can still be paid; permanent.

Link ids look like `lnk_` + 20 characters; link URLs `https://pay.caravelpay.app/l/lnk_…`.

### 4.7 `POST /v1/reports` (unsigned)

Report a seller: `{url, category (illegal_goods|scam_fraud|impersonation|other), details (1–2000 chars), checkout_id?, link_id?, contact_email?, turnstile_token?}` → `202 {id: "rpt_…"}`. 5 per hour per IP.

## 5. Objects

### 5.1 Checkout

```json
{
  "id": "chk_8Qe2TnV4kR",
  "checkout_url": "https://pay.caravelpay.app/c/chk_8Qe2TnV4kR",
  "status": "paid",
  "amount": "136.41",
  "currency": "USD",
  "expires_at": "2026-11-09T15:04:12Z",
  "order_id": "1042",
  "created_at": "2026-11-09T14:04:12Z",
  "paid_amount": "136.41",
  "payout": {"amount": "133.68", "asset": "USDC", "tx_hash": "0x5d1e8c0b7a4f2e19c6d3b8a0f5e7c2d1a9b4e6f8c0d2a7b5e3f1c9d8a6b4a07b"}
}
```

- Times are ISO 8601 UTC with whole seconds (`…Z`). Amounts are strings; never parse them into floats
  for comparisons (compare cents as integers or strings).
- `paid_amount` is always present (`"0.00"` before payment).
- `payout` appears once paid: `amount` (in `asset`, up to 18 decimals), `asset` (`USDC`, `USDC.e` or
  `POL`), `tx_hash` (Polygon transaction to the merchant's wallet; explorer link
  `https://polygonscan.com/tx/<tx_hash>`).
- `payouts[]` appears only when the payment went out as more than one transfer (e.g. part USDC, part
  USDC.e); `payout` is then the first. Add them up; never assume a single transfer.
- The create response (`201`/`200`) has only `id, checkout_url, status, amount, currency, expires_at`.

### 5.2 Statuses

| Status | Meaning | What the integration does |
|---|---|---|
| `awaiting_payment` | created; not paid yet | nothing (order pending) |
| `confirming` | payment detected, waiting for confirmation | nothing yet; don't ship |
| `paid` | full amount arrived and both payout transfers (merchant and fee) confirmed | fulfil; record `payout.tx_hash` |
| `underpaid` | less than `amount` arrived (`paid_amount` says how much) | hold; contact the buyer (they can top up before expiry) |
| `expired` | no payment within 60 minutes | cancel or release stock, **but** a later `paid` must still complete the order |
| `review` | needs a manual look (e.g. payment sent after expiry, unsupported token) | hold; a follow-up event comes if it resolves |

Late transitions that happen: `underpaid` → `paid` (top-up), `expired` → `paid` (on-time payment found
late), any status → `review`.

### 5.3 Webhook event

`POST` to the checkout's `callback_url`, `Content-Type: application/json`, body:

```json
{"id": "evt_3Lk9Wq2Zr7Yp", "type": "checkout.paid", "created": "2026-11-09T14:09:37Z", "data": { "...": "the full checkout object (§5.1)" }}
```

- `type`: `checkout.paid`, `checkout.underpaid`, `checkout.expired` or `checkout.review`. Acknowledge
  (2xx) unknown types: new ones may be added.
- Headers: `Caravel-Merchant`, `Caravel-Timestamp`, `Caravel-Signature` (§3), `Caravel-Event` (= body
  `id`, same on every retry), `User-Agent: CaravelPay-Webhooks/1`.
- Delivery: any 2xx within 10 seconds marks it delivered. Otherwise retried at 1, 5, 15 and 30 minutes,
  then 1, 2 and 3 hours after the first attempt, then every 3 hours until 24 hours after it (15 attempts),
  each with a fresh timestamp and signature over the same body bytes.
- Use `data.id` to find your order, then re-fetch `GET /v1/checkouts/{data.id}` (rule 6).

## 6. Errors

Non-2xx responses carry `{"error": {"code", "message", "param"?}}`. Branch on `code`; `message` is for
humans and may change; `param` names the field or header. `429` and `503` carry `Retry-After` (seconds).

| code | HTTP | meaning | retry? |
|---|---|---|---|
| `invalid_request` | 400 | malformed JSON, unknown/missing field, bad query parameter, body over 16 KiB | no; fix `param` |
| `invalid_amount` | 400 | `amount` isn't a two-decimal string in range | no |
| `unsupported_currency` | 400 | `currency` isn't `"USD"` | no |
| `invalid_url` | 400 | a URL isn't absolute HTTPS on the merchant's origins (on connect: `site_url` invalid) | no |
| `unknown_merchant` | 401 | `Caravel-Merchant` missing or unknown | no; check credentials |
| `invalid_signature` | 401 | signature missing or wrong | no; fix signing (path+query, raw body) |
| `timestamp_expired` | 401 | timestamp missing, malformed or more than 300 s off | after fixing the server clock (NTP) |
| `merchant_paused` | 403 | new checkouts paused for this merchant | no; open checkouts still finish |
| `merchant_pending_review` | 403 | merchant awaiting approval | later |
| `not_found` | 404 | no such checkout/link for this merchant, or no such endpoint | no |
| `order_id_conflict` | 409 | `order_id` already used with different details | no; new `order_id` |
| `limit_exceeded` | 409 | a per-merchant limit (order size, daily count/volume, open checkouts), or the wallet's merchant limit on connect | no / later |
| `rate_limited` | 429 | too many requests | yes, after `Retry-After` |
| `server_error` | 500 | unexpected error | yes, with backoff and the same `order_id` |
| `service_unavailable` | 503 | new checkouts (or new merchants) briefly unavailable | yes, after `Retry-After`, same `order_id` |

Rate limits: 600 signed requests per minute per merchant; 60 failed authentications per minute per IP;
`POST /v1/connect` 10 per hour per IP plus a global limit.

## 7. Test vectors

Synthetic credentials (they don't work against the API). Your `sign()` must reproduce these exactly.

| name | merchant | timestamp | method | path | body | signature |
|---|---|---|---|---|---|---|
| docs_create_checkout | `mch_7Q2KZX4PRB` | `1794233052` | POST | `/v1/checkouts` | `{"order_id":"1042","amount":"136.41","currency":"USD","return_url":"https://shop.example/thanks","cancel_url":"https://shop.example/cart","callback_url":"https://shop.example/webhooks/caravel"}` | `34fd1584ba0f542dd68864a0a4c77df7c2a8532a4b254b4e865ad98e78ee2c4c` |
| get_checkout | `mch_7Q2KZX4PRB` | `1794233100` | GET | `/v1/checkouts/chk_8Qe2TnV4kR` | (empty) | `2daaf4b546a94fecbf2ad273d725568c0de02f49e86b3e62454d39e82418813a` |
| list_checkouts_with_query | `mch_7Q2KZX4PRB` | `1794233160` | GET | `/v1/checkouts?limit=2` | (empty) | `4e9dab237329abd0d597f2874075a7af4117e576f006b455386a8fb8a2155702` |
| webhook_checkout_paid | `mch_7Q2KZX4PRB` | `1794233377` | POST | `/webhooks/caravel?shop=main` | the `checkout.paid` body in `signature-vectors.json` | `e6da986d9282ac3f6c8acf606ce90188198d61eb3b6e9cc205de917cf76ef77a` |

Secret for all four: `9b2e7c4a1f6d8e3b5a0c9d2f7e4b1a6c3d8f5e2b9a7c4d1e6f3b8a5c2d9e7f4a`.
The full vectors (with `string_to_sign`) are published as `signature-vectors.json`.

## 8. Ready-made integrations

Prefer these over hand-written code when the merchant uses one of these platforms. Each verifies,
re-fetches and maps statuses as above:

- WooCommerce plugin; PrestaShop 1.7.7+/8 module; OpenCart 4.0.2+ extension (`caravelpay.ocmod.zip`);
  Magento 2 / Adobe Commerce / Mage-OS module; WHMCS, Easy Digital Downloads, FOSSBilling, Paymenter,
  Blesta, HostBill and Clientexec gateways.
- Laravel package `caravelpay/caravel-laravel` (webhook route + `caravel.webhook` middleware, events
  `CheckoutPaid`, `CheckoutUnderpaid`, `CheckoutExpired`, `CheckoutReview`).
- PHP SDK `caravelpay/caravel-php` (`CaravelPay\Client`, `CaravelPay\Webhook::verify()`) and the Node.js SDK.

## 9. Common mistakes

- Signing the full URL, or dropping the query string from `path`.
- Re-serializing the JSON body after signing it (key order or whitespace changes the bytes), or
  verifying a webhook against parsed-and-re-encoded JSON instead of the raw body.
- Hex-decoding the secret before using it as the HMAC key (use it as an ASCII string).
- Uppercase hex signatures, or comparing with `==` instead of a constant-time comparison.
- Sending `amount` as a number (`136.4`) instead of the string `"136.40"`; computing totals with floats.
- Marking an order paid when the buyer lands on `return_url`, or from the webhook body without
  re-fetching the checkout.
- Ignoring `checkout.paid` after `checkout.expired`.
- Creating a second checkout for the same order while the first is still open (reuse it; a new
  `order_id` only after `expired`).
- Throwing away the old secret immediately after reconnecting.
- A webhook handler that does slow work before answering, takes more than 10 seconds, or answers non-2xx
  for a duplicate.
- Sending `customer_email`, URLs or amounts that differ on a retry of the same `order_id` (`409`).
