Caravel Pay Docs

API reference

Webhooks

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#

TypeSent when
checkout.paidThe 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.underpaidLess than the checkout amount arrived.
checkout.expiredThe checkout passed expires_at without payment.
checkout.reviewA payment needs a manual look.

Payload#

Every event has the same shape. data is the full checkout object at the time of the event.

POST {callback_url}
{
  "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"
    }
  }
}
FieldDescription
idstringEvent id. The same event keeps the same id on every retry.
typestringOne of the event types above.
createdstringWhen the event happened (ISO 8601, UTC).
dataobjectThe 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
${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
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);
});

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}; 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
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.