# Webhooks

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

When a checkout settles, Caravel Pay sends a signed `POST` to the checkout's `callback_url`. Verify it, answer `2xx`, and update the order.

## Events

| Type | Sent when |
|---|---|
| `checkout.paid` | The full amount confirmed and your payout was sent. Fulfil the order. Rarely it follows `checkout.expired`, when a payment sent before expiry confirmed late; fulfil it then too. |
| `checkout.underpaid` | Less than the checkout amount arrived. |
| `checkout.expired` | The checkout passed `expires_at` without payment. |
| `checkout.review` | A payment needs a manual look. |

## Payload

Every event has the same shape. `data` is the full [checkout object](https://caravelpay.app/docs/checkouts.html#object) at the time of the event.

*POST {callback_url}*

```json
{
  "id": "evt_3Lk9Wq2Zr7Yp",
  "type": "checkout.paid",
  "created": "2026-11-09T14:09:37Z",
  "data": {
    "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",
    "paid_amount": "136.41",
    "payout": {
      "amount": "133.68",
      "asset": "USDC",
      "tx_hash": "0x5d1e8c0b7a4f2e19c6d3b8a0f5e7c2d1a9b4e6f8c0d2a7b5e3f1c9d8a6b4a07b"
    }
  }
}
```

| Field | Description |
|---|---|
| `id` · string | Event id. The same event keeps the same id on every retry. |
| `type` · string | One of the event types above. |
| `created` · string | When the event happened (ISO 8601, UTC). |
| `data` · object | The checkout. Find your order by `data.id`, the checkout id you saved when you created it. |

If the buyer paid with more than one token, for example part in USDC and part in USDC.e, each token is paid out as itself in its own transfer: `data.payout` is the first transfer and `data.payouts` lists every one, oldest first. Add them up for the total; never assume a single transfer. With a single transfer, `payouts` is left out.

Each webhook is signed with the secret of the merchant id that created the checkout, which is the one in `Caravel-Merchant`. If you get new keys, keep the old ones until the checkouts created with them have finished, so their webhooks still verify.

## Verify the signature

Webhooks carry the same three headers as API requests: `Caravel-Merchant`, `Caravel-Timestamp` and `Caravel-Signature`. Two more come with every webhook: `Caravel-Event`, the event id (always equal to `id` in the body and the same on every retry, so after verifying the signature you can check it against the ids you've processed without parsing the body), and `User-Agent: CaravelPay-Webhooks/1`. Neither is part of the signature. The signature covers the webhook's method (`POST`), the path of your `callback_url` (with its query string, if it has one) and the raw body:

*String to sign*

```text
${timestamp}\nPOST\n${callback path}\n${raw body}
```

1. **Read the raw body** before any JSON parsing. Parsed-and-re-encoded JSON won't match.
2. **Recompute the signature** with your secret and compare it to `Caravel-Signature` using a constant-time comparison.
3. **Check the timestamp** is within 300 seconds of your clock, so an old request can't be replayed.
4. **Reject anything else** with `401`.

*Verify and handle a webhook · Node.js*

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

const app = express();
const SECRET = process.env.CARAVEL_SECRET;

// express.raw keeps the exact bytes we signed
app.post("/webhooks/caravel", express.raw({ type: "application/json" }), async (req, res) => {
  const timestamp = req.get("Caravel-Timestamp") ?? "";
  const signature = req.get("Caravel-Signature") ?? "";
  const expected = crypto
    .createHmac("sha256", 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 (await db.events.has(event.id)) return res.sendStatus(200); // already handled

  const order = await db.orders.findByCheckoutId(event.data.id);
  switch (event.type) {
    case "checkout.paid":
      await order.markPaid(event.data.payout.tx_hash);
      break;
    case "checkout.underpaid":
    case "checkout.review":
      await order.hold(event.type);
      break;
    case "checkout.expired":
      await order.cancel();
      break;
  }
  await db.events.add(event.id);
  res.sendStatus(200);
});
```

*Verify and handle a webhook · PHP*

```php
<?php
$secret    = getenv('CARAVEL_SECRET');
$body      = file_get_contents('php://input');            // raw bytes
$timestamp = $_SERVER['HTTP_CARAVEL_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_CARAVEL_SIGNATURE'] ?? '';
$path      = $_SERVER['REQUEST_URI'];                     // path + query string

$expected = hash_hmac('sha256', "$timestamp\nPOST\n$path\n$body", $secret);

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

$event = json_decode($body, true);
if (event_already_handled($event['id'])) {
    http_response_code(200);
    exit;
}

$order = find_order_by_checkout_id($event['data']['id']);
switch ($event['type']) {
    case 'checkout.paid':
        mark_order_paid($order, $event['data']['payout']['tx_hash']);
        break;
    case 'checkout.underpaid':
    case 'checkout.review':
        hold_order($order, $event['type']);
        break;
    case 'checkout.expired':
        cancel_order($order);
        break;
}
remember_event($event['id']);
http_response_code(200);
```

*Verify and handle a webhook · Python*

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

app = Flask(__name__)
SECRET = os.environ["CARAVEL_SECRET"].encode()

@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(SECRET, 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_already_handled(event["id"]):
        return "", 200

    order = find_order_by_checkout_id(event["data"]["id"])
    if event["type"] == "checkout.paid":
        mark_order_paid(order, event["data"]["payout"]["tx_hash"])
    elif event["type"] in ("checkout.underpaid", "checkout.review"):
        hold_order(order, event["type"])
    elif event["type"] == "checkout.expired":
        cancel_order(order)

    remember_event(event["id"])
    return "", 200
```

> **Behind a proxy?** Sign over the path exactly as it appears in your `callback_url`. If a reverse proxy or framework mount rewrites the path, use the original one.

## Responses and retries

- **Answer with any `2xx` within 10 seconds** once you've stored the event; that marks it delivered. Do slow work, like emails, after responding.
- **Anything else is retried.** Non-`2xx` responses, timeouts and connection errors are retried at 1, 5, 15 and 30 minutes, then at 1, 2 and 3 hours after the first attempt, then every 3 hours until 24 hours after it: 15 attempts in all. After that the event isn't sent again.
- **Each attempt is signed fresh,** with a new timestamp over the same body bytes, so the 300-second check still works on retries.

## Handle duplicates

Retries mean the same event can arrive more than once. Make your handler idempotent on the event `id`: store ids you've processed and answer `200` without doing the work again.

Events can also arrive out of order. If you need the latest state, read the checkout with [GET /v1/checkouts/{id}](https://caravelpay.app/docs/checkouts.html#retrieve); its `status` is always current.

## Test your endpoint

Send yourself a signed test event with curl to check your verification code before going live:

*Send a signed test event · curl*

```sh
URL=https://shop.example/webhooks/caravel
URL_PATH=/webhooks/caravel
BODY='{"id":"evt_test_1","type":"checkout.paid","created":"2026-11-09T14:09:37Z","data":{"id":"chk_test","status":"paid","amount":"1.00","currency":"USD","paid_amount":"1.00","payout":{"amount":"0.98","asset":"USDC","tx_hash":"0x0"}}}'
TS=$(date +%s)
SIG=$(printf '%s\n%s\n%s\n%s' "$TS" POST "$URL_PATH" "$BODY" \
  | openssl dgst -sha256 -hmac "$CARAVEL_SECRET" | awk '{print $NF}')

curl -i "$URL" \
  -H "Content-Type: application/json" \
  -H "Caravel-Merchant: $CARAVEL_MERCHANT" \
  -H "Caravel-Timestamp: $TS" \
  -H "Caravel-Signature: $SIG" \
  --data-binary "$BODY"
```

You should get `200`. Change one character of `BODY` after signing and you should get `401`.
