Caravel Pay Docs

Get started

Quickstart

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.

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. 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
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 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
BODY='{"order_id":"1042","amount":"136.41","currency":"USD","customer_email":"[email protected]","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"
201 Created
{
  "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}.

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

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 covers every event type and the retry rules.

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

data.payout
{
  "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#