# Quickstart

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

Take a card payment and receive USDC on Polygon in your own wallet. Create a checkout, send the buyer to it, and mark the order paid when the webhook arrives.

- **WooCommerce plugin** (No code): Install, paste your wallet, done. The plugin handles checkouts and webhooks for you. [Plugin guide](https://caravelpay.app/docs/woocommerce.html)

- **The API** (Any platform): One call for your keys, then three small pieces on your server: create a checkout, redirect, handle one webhook. [Start below](https://caravelpay.app/docs/#create)

## Before you start

- **A payout wallet.** Any ordinary Polygon address you control. Payouts arrive there in the token the buyer paid with, never swapped; card payments arrive as USDC.
- **A merchant id and secret.** One unsigned call to `POST /v1/connect` returns both: see [Get your API keys](https://caravelpay.app/docs/connect.html). The WooCommerce plugin makes that call for you. Keep the secret on your server.
- **An HTTPS endpoint** on your site that can receive webhooks.

The examples read your credentials from two environment variables:

*Environment*

```sh
export CARAVEL_MERCHANT="mch_..."   # merchant_id from POST /v1/connect
export CARAVEL_SECRET="..."         # secret from the same response, shown once
```

All requests go to `https://api.caravelpay.app`. Apart from getting your keys, each one is signed with HMAC-SHA256. The helpers below do the signing; [Authentication](https://caravelpay.app/docs/authentication.html) explains it line by line.

## 1. Create a checkout

When the buyer clicks "pay" in your store, create a checkout for the order on your server. Amounts are strings with two decimals, in US dollars.

*POST /v1/checkouts · curl*

```sh
BODY='{"order_id":"1042","amount":"136.41","currency":"USD","customer_email":"buyer@example.com","return_url":"https://shop.example/thanks","cancel_url":"https://shop.example/cart","callback_url":"https://shop.example/webhooks/caravel"}'
TS=$(date +%s)
SIG=$(printf '%s\n%s\n%s\n%s' "$TS" POST /v1/checkouts "$BODY" \
  | openssl dgst -sha256 -hmac "$CARAVEL_SECRET" | awk '{print $NF}')

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

*POST /v1/checkouts · Node.js*

```js
// Node.js 18+ (built-in fetch). Save as caravel.mjs
import crypto from "node:crypto";

const API = "https://api.caravelpay.app";

export async function caravel(method, path, payload) {
  const body = payload ? JSON.stringify(payload) : "";
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const signature = crypto
    .createHmac("sha256", process.env.CARAVEL_SECRET)
    .update(`${timestamp}\n${method}\n${path}\n${body}`)
    .digest("hex");

  const res = await fetch(API + path, {
    method,
    headers: {
      "Content-Type": "application/json",
      "Caravel-Merchant": process.env.CARAVEL_MERCHANT,
      "Caravel-Timestamp": timestamp,
      "Caravel-Signature": signature,
    },
    body: body || undefined,
  });
  const data = await res.json();
  if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message}`);
  return data;
}

const checkout = await caravel("POST", "/v1/checkouts", {
  order_id: "1042",
  amount: "136.41",
  currency: "USD",
  customer_email: "buyer@example.com",
  return_url: "https://shop.example/thanks",
  cancel_url: "https://shop.example/cart",
  callback_url: "https://shop.example/webhooks/caravel",
});
```

*POST /v1/checkouts · PHP*

```php
<?php
// PHP 7.4+ with the curl extension
function caravel(string $method, string $path, ?array $payload = null): array
{
    $body = $payload === null ? '' : json_encode($payload, JSON_UNESCAPED_SLASHES);
    $timestamp = (string) time();
    $signature = hash_hmac('sha256', "$timestamp\n$method\n$path\n$body", getenv('CARAVEL_SECRET'));

    $options = [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 15,
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            'Caravel-Merchant: ' . getenv('CARAVEL_MERCHANT'),
            'Caravel-Timestamp: ' . $timestamp,
            'Caravel-Signature: ' . $signature,
        ],
    ];
    if ($body !== '') {
        $options[CURLOPT_POSTFIELDS] = $body;
    }

    $ch = curl_init('https://api.caravelpay.app' . $path);
    curl_setopt_array($ch, $options);
    $data = json_decode((string) curl_exec($ch), true);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);

    if ($status < 200 || $status >= 300) {
        throw new RuntimeException(($data['error']['code'] ?? 'http_' . $status) . ': ' . ($data['error']['message'] ?? ''));
    }
    return $data;
}

$checkout = caravel('POST', '/v1/checkouts', [
    'order_id'       => '1042',
    'amount'         => '136.41',
    'currency'       => 'USD',
    'customer_email' => 'buyer@example.com',
    'return_url'     => 'https://shop.example/thanks',
    'cancel_url'     => 'https://shop.example/cart',
    'callback_url'   => 'https://shop.example/webhooks/caravel',
]);
```

*POST /v1/checkouts · Python*

```py
# Python 3.8+ with requests (pip install requests). Save as caravel.py
import hashlib, hmac, json, os, time
import requests

API = "https://api.caravelpay.app"

def caravel(method, path, payload=None):
    body = "" if payload is None else json.dumps(payload, separators=(",", ":"))
    timestamp = str(int(time.time()))
    message = f"{timestamp}\n{method}\n{path}\n{body}".encode()
    signature = hmac.new(os.environ["CARAVEL_SECRET"].encode(), message, hashlib.sha256).hexdigest()

    res = requests.request(method, API + path, data=body.encode() or None, timeout=15, headers={
        "Content-Type": "application/json",
        "Caravel-Merchant": os.environ["CARAVEL_MERCHANT"],
        "Caravel-Timestamp": timestamp,
        "Caravel-Signature": signature,
    })
    data = res.json()
    if not res.ok:
        raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}")
    return data

checkout = caravel("POST", "/v1/checkouts", {
    "order_id": "1042",
    "amount": "136.41",
    "currency": "USD",
    "customer_email": "buyer@example.com",
    "return_url": "https://shop.example/thanks",
    "cancel_url": "https://shop.example/cart",
    "callback_url": "https://shop.example/webhooks/caravel",
})
```

*201 Created*

```json
{
  "id": "chk_8Qe2TnV4kR",
  "checkout_url": "https://pay.caravelpay.app/c/chk_8Qe2TnV4kR",
  "status": "awaiting_payment",
  "amount": "136.41",
  "currency": "USD",
  "expires_at": "2026-11-09T15:04:12Z"
}
```

Save the checkout `id` on the order. Webhooks refer to the checkout by this id.

> **Safe to retry.** `order_id` is unique per merchant. If a request times out, send it again with the same `order_id`: you get the same checkout back, never a second one.

## 2. Send the buyer to checkout_url

Redirect the buyer to `checkout_url`. They pay by card, Apple Pay or Google Pay on the hosted page; the card provider's own fee is shown to them there before they confirm.

Afterwards the buyer comes back to your `return_url`, or to `cancel_url` if they give up. A checkout that isn't paid within 60 minutes expires.

> **A return visit is not a payment.** Anyone can open your `return_url`. Only mark an order paid from a verified webhook, or after reading the checkout with [GET /v1/checkouts/{id}](https://caravelpay.app/docs/checkouts.html#retrieve).

## 3. Handle the webhook

When the checkout settles, Caravel Pay sends a signed `POST` to your `callback_url`. Verify the signature, check you haven't seen the event before, then update the order.

*POST /webhooks/caravel · Node.js*

```js
import crypto from "node:crypto";
import express from "express";

const app = express();

app.post("/webhooks/caravel", express.raw({ type: "application/json" }), (req, res) => {
  const timestamp = req.get("Caravel-Timestamp") ?? "";
  const signature = req.get("Caravel-Signature") ?? "";
  const expected = crypto
    .createHmac("sha256", process.env.CARAVEL_SECRET)
    .update(`${timestamp}\n${req.method}\n${req.originalUrl}\n${req.body}`)
    .digest("hex");

  const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
  const valid = signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  if (!fresh || !valid) return res.sendStatus(401);

  const event = JSON.parse(req.body);
  if (event.type === "checkout.paid") {
    markOrderPaid(event.data.id, event.id); // idempotent on event.id
  }
  res.sendStatus(200);
});
```

*POST /webhooks/caravel · PHP*

```php
<?php
$body      = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_CARAVEL_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_CARAVEL_SIGNATURE'] ?? '';
$expected  = hash_hmac('sha256', "$timestamp\nPOST\n{$_SERVER['REQUEST_URI']}\n$body", getenv('CARAVEL_SECRET'));

if (abs(time() - (int) $timestamp) > 300 || !hash_equals($expected, $signature)) {
    http_response_code(401);
    exit;
}

$event = json_decode($body, true);
if ($event['type'] === 'checkout.paid') {
    mark_order_paid($event['data']['id'], $event['id']); // idempotent on event id
}
http_response_code(200);
```

*POST /webhooks/caravel · Python*

```py
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/webhooks/caravel")
def caravel_webhook():
    timestamp = request.headers.get("Caravel-Timestamp", "")
    signature = request.headers.get("Caravel-Signature", "")
    path = request.full_path.rstrip("?")  # path plus query string, if any
    message = f"{timestamp}\n{request.method}\n{path}\n".encode() + request.get_data()
    expected = hmac.new(os.environ["CARAVEL_SECRET"].encode(), message, hashlib.sha256).hexdigest()

    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        abort(401)
    if not hmac.compare_digest(expected, signature):
        abort(401)

    event = request.get_json()
    if event["type"] == "checkout.paid":
        mark_order_paid(event["data"]["id"], event["id"])  # idempotent on event id
    return "", 200
```

Webhooks are retried with backoff for up to 24 hours until you answer with a `2xx`, so the same event can arrive more than once. [Webhooks](https://caravelpay.app/docs/webhooks.html) covers every event type and the retry rules.

## 4. Get paid

Once the payment confirms, the checkout moves to `paid` and the payment, minus the 2% Caravel fee, is sent to your payout wallet as USDC on Polygon. There is nothing to withdraw. The `checkout.paid` event includes the payout:

*data.payout*

```json
{
  "amount": "133.68",
  "asset": "USDC",
  "tx_hash": "0x5d1e8c0b7a4f2e19c6d3b8a0f5e7c2d1a9b4e6f8c0d2a7b5e3f1c9d8a6b4a07b"
}
```

Look up `tx_hash` on any Polygon block explorer to see the transfer to your wallet.

## Next steps

- [Checkout statuses](https://caravelpay.app/docs/statuses.html): what `underpaid`, `expired` and `review` mean for your store.
- [Verify webhooks](https://caravelpay.app/docs/webhooks.html#verify) in more detail, and test them locally.
- [Payouts and fees](https://caravelpay.app/docs/payouts.html): the 2% Caravel fee, timing and accepted tokens.
- [Errors](https://caravelpay.app/docs/errors.html): every error code and when to retry.
