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/connectreturns 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:
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.
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"// 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: "[email protected]",
return_url: "https://shop.example/thanks",
cancel_url: "https://shop.example/cart",
callback_url: "https://shop.example/webhooks/caravel",
});<?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' => '[email protected]',
'return_url' => 'https://shop.example/thanks',
'cancel_url' => 'https://shop.example/cart',
'callback_url' => 'https://shop.example/webhooks/caravel',
]);# 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": "[email protected]",
"return_url": "https://shop.example/thanks",
"cancel_url": "https://shop.example/cart",
"callback_url": "https://shop.example/webhooks/caravel",
}){
"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.
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);
});<?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);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 "", 200Webhooks 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.
4. Get paid#
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:
{
"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: what
underpaid,expiredandreviewmean for your store. - Verify webhooks in more detail, and test them locally.
- Payouts and fees: the 2% fee, timing and accepted tokens.
- Errors: every error code and when to retry.