Quickstart
Quickstart
From a sandbox key to a webhook you verify yourself, in six requests. Every response body below is the one the sandbox returns.
Updated
Ten minutes, six steps, one terminal. Everything here runs against the sandbox: no card is real, no money moves, and nothing you do can reach a live acquirer. Set your key once and the rest is copy-and-paste.
export CLEARED_SECRET_KEY="cp_sk_test_…"
export CLEARED_BASE_URL="https://cleared-pay.com/api/v1"1. Get a test key
Onboarding is underwritten, so there is no self-serve signup — the qualification form is the way in. A test key is issued with the account, before any contract and before any live connector exists, so you can build the whole integration while underwriting runs.
Keys come in two shapes:
| Prefix | Length | Where it may be used |
|---|---|---|
cp_pk_test_… | 26 characters | In the browser, for tokenisation only. Never for a payment. |
cp_sk_test_… | 32 characters | Server-side only. Everything in this guide uses this one. |
The environment is the key: a _test_ key always routes to the simulator and a _live_ key never does. There is no mode parameter to forget.
A secret key is shown exactly once at creation and stored as a hash with a pepper, so it cannot be recovered — if you lose it, roll it. TODO(portal): the self-service key screen ships with the merchant portal at /portal/developers/api-keys (create, roll with a grace window, revoke, last-used and request counts). Until it is live your test key comes from the person who opens your account; nothing on this site issues one.
2. Tokenise a test card
POST /v1/payments never accepts a card number. In production the token comes from hosted fields or the hosted checkout page; in the sandbox you mint one from a magic card number. This endpoint is the only one in the API that accepts card data, it exists in test mode only, and it keeps brand, last4, BIN, expiry and a fingerprint — never the number or the security code.
curl "$CLEARED_BASE_URL/sandbox/tokens" \
-H "Authorization: Bearer $CLEARED_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"type":"card","number":"4242424242424242","exp_month":12,"exp_year":2029,"cvc":"123"}'{
"id": "tok_01JBXQ2M4K7T9V3ZC8HR5N6PWD",
"object": "token",
"type": "card",
"card": {
"brand": "visa",
"last4": "4242",
"bin": "424242",
"exp_month": 12,
"exp_year": 2029
},
"livemode": false,
"created": "2026-08-19T10:15:00Z",
"expires_at": "2026-08-19T10:30:00Z"
}Tokens are single-use and valid for 15 minutes. Any future expiry and any 3 or 4 digit security code are accepted; a number that is not in the magic-card table is rejected with invalid_card_number.
3. Create a payment
Amounts are integers in minor units and currencies are lowercase ISO 4217, so 2000 and eur mean €20.00. capture_method: "automatic" authorises and captures in one call.
curl "$CLEARED_BASE_URL/payments" \
-H "Authorization: Bearer $CLEARED_SECRET_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount": 2000,
"currency": "eur",
"reference": "ORDER-10432",
"capture_method": "automatic",
"payment_method": { "type": "card", "token": "tok_01JBXQ2M4K7T9V3ZC8HR5N6PWD" },
"description": "Order 10432",
"metadata": { "cart_id": "c_881" }
}'201 Created, abridged — the full field list is in payments:
{
"id": "pay_01JBXQ2M4K7T9V3ZC8HR5N6PWD",
"object": "payment",
"status": "captured",
"amount": 2000,
"amount_captured": 2000,
"amount_refunded": 0,
"currency": "eur",
"reference": "ORDER-10432",
"capture_method": "automatic",
"method": { "type": "card", "card": { "brand": "visa", "last4": "4242", "bin": "424242" } },
"outcome": {
"response_code": "00",
"decline_code": null,
"decline_is_soft": null,
"three_ds": { "status": "authenticated", "eci": "05", "version": "2.2.0" }
},
"livemode": false,
"created": "2026-08-19T10:15:00Z",
"authorized_at": "2026-08-19T10:15:01Z",
"captured_at": "2026-08-19T10:15:01Z"
}Send the same Idempotency-Key with the same body again and you get that response back byte for byte for 24 hours, with an Idempotent-Replayed: true header. Send it with a different body and you get 409 idempotency_error.
Two other outcomes worth seeing now
Both are normal, both will happen in production, and both are one request away.
A challenge — requires_action. Tokenise 4000 0000 0000 3220 and create the payment with a return_url. The response is 201 with status: "requires_action" and a redirect to the simulated challenge page:
{
"id": "pay_01JBXQ4N7P2R8T5WY9AC3EF6HK",
"status": "requires_action",
"next_action": {
"type": "redirect",
"redirect": {
"url": "https://cleared-pay.com/3ds-test/sbx_4f0a2c6e9b314c0d9a7e1f2b",
"return_url": "https://merchant.example/return"
}
}
}Open that URL and choose Approve to run the card's planned outcome, Decline for a soft sca_required decline with three_ds.status: "failed", or Timeout to fail the payment. Card 4000 0000 0000 3063 challenges and then declines.
An async rail — processing. Tokenise 4000 0000 0000 0259 and the payment stays processing for 30 seconds before it captures:
{
"id": "pay_01JBXQ6P9R4T2V7XZ1BD5GH8KM",
"status": "processing",
"amount": 2000,
"amount_captured": 0,
"currency": "eur"
}Wait it out, read the payment back, or force it:
curl -X POST "$CLEARED_BASE_URL/sandbox/advance/pay_01JBXQ6P9R4T2V7XZ1BD5GH8KM" \
-H "Authorization: Bearer $CLEARED_SECRET_KEY"A decline is not an error in your code's control flow, but it is an HTTP error: a declined payment answers 402 with type: "card_error" and the payment id in the message, and a processing failure answers 422. In both cases the payment exists and GET returns it.
4. Read the payment back
curl "$CLEARED_BASE_URL/payments/pay_01JBXQ2M4K7T9V3ZC8HR5N6PWD" \
-H "Authorization: Bearer $CLEARED_SECRET_KEY"GET always returns the payment object, whatever the status, and a processing payment past its resolve time is advanced on read. Lists are cursor-paginated and filterable by status, currency, reference, customer, method type, creation date and amount:
curl -G "$CLEARED_BASE_URL/payments" \
-H "Authorization: Bearer $CLEARED_SECRET_KEY" \
-d "status=captured" -d "currency=eur" -d "limit=10"5. Register a webhook endpoint
The URL must be https and must not resolve to a loopback, private, link-local or metadata address — checked when you register it and again before every delivery, so a rebinding DNS record cannot turn a public hostname into an internal one. For local development use a tunnel that gives you a public https URL.
curl "$CLEARED_BASE_URL/webhook-endpoints" \
-H "Authorization: Bearer $CLEARED_SECRET_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.example/webhooks/cleared-pay",
"description": "Sandbox receiver",
"enabled_events": ["payment.captured", "refund.*"]
}'{
"id": "whe_01JBXQ8Q2T6V4X9Z3CF7HJ1LNP",
"object": "webhook_endpoint",
"url": "https://your-app.example/webhooks/cleared-pay",
"description": "Sandbox receiver",
"enabled_events": ["payment.captured", "refund.*"],
"status": "enabled",
"secret": "cp_whsec_…",
"secret_last4": "9f2a",
"previous_secret_expires_at": null,
"livemode": false,
"created": "2026-08-19T10:16:00Z"
}secret appears here and on roll-secret and nowhere else — store it now as CLEARED_WEBHOOK_SECRET. Omit enabled_events and the endpoint receives everything; payment.* and * are accepted alongside exact types. At most 16 enabled endpoints per environment.
6. Receive and verify the event
Verify the signature over the raw request body before you parse it. Cleared-Signature carries t=<unix seconds> and one v1=<hex> per active secret, the signed payload is t + "." + raw body, the tolerance is 300 seconds, and the comparison is constant-time with the lengths checked first. Then return 2xx fast and process asynchronously, deduplicating on event.id — a retry of a delivery you already handled must not charge anyone twice.
The three published snippets are on the webhooks reference; this is the Node one:
import { createHmac, timingSafeEqual } from 'node:crypto';
export async function POST(req: Request) {
const raw = await req.text(); // RAW body — do not JSON.parse first
const header = req.headers.get('cleared-signature') ?? '';
const parts = header.split(',').map((p) => p.split('='));
const t = Number(parts.find(([k]) => k === 't')?.[1]);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return new Response('stale', { status: 400 });
const expected = createHmac('sha256', process.env.CLEARED_WEBHOOK_SECRET!)
.update(`${t}.${raw}`).digest('hex');
const a = Buffer.from(expected);
// one v1 per active secret during a rotation: accept any that matches
const ok = parts
.filter(([k]) => k === 'v1')
.some(([, v]) => {
const b = Buffer.from(v ?? '');
return a.length === b.length && timingSafeEqual(a, b); // length first: timingSafeEqual throws on mismatch
});
if (!ok) return new Response('bad signature', { status: 400 });
const event = JSON.parse(raw);
await enqueue(event); // dedupe on event.id, process async
return new Response('ok', { status: 200 }); // return fast
}Now fire an event at it without waiting for another payment. POST /v1/sandbox/simulate-webhook stores a real event — using the resource you name, else the newest matching one, else a clearly synthetic sample — and delivers it inline, so the HTTP result of your receiver comes back in the response:
curl "$CLEARED_BASE_URL/sandbox/simulate-webhook" \
-H "Authorization: Bearer $CLEARED_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"type":"payment.captured","resource":"pay_01JBXQ2M4K7T9V3ZC8HR5N6PWD"}'{
"id": "evt_01JBXQ9R4V8X6Z2B5DG9KL3NQS",
"object": "event",
"type": "payment.captured",
"api_version": "2026-08-19",
"livemode": false,
"created": "2026-08-19T10:17:00Z",
"data": { "object": { "id": "pay_01JBXQ2M4K7T9V3ZC8HR5N6PWD", "status": "captured" } },
"attempts": [
{
"id": "wha_01JBXQ9R6X2Z8B4D7FH1MN5QTV",
"object": "webhook_attempt",
"event": "evt_01JBXQ9R4V8X6Z2B5DG9KL3NQS",
"endpoint": "whe_01JBXQ8Q2T6V4X9Z3CF7HJ1LNP",
"attempt": 1,
"status": "delivered",
"http_status": 200,
"duration_ms": 142,
"next_retry_at": null,
"attempted_at": "2026-08-19T10:17:00Z"
}
]
}"status": "delivered" with "http_status": 200 is the end of the ten minutes: you have created a payment, read it back, and verified a signed event with your own code.
If it says failed instead, the response_body field on the attempt carries the first 2 KB of what your endpoint returned, which is usually enough to see why. Real deliveries retry on the schedule 10s, 1m, 5m, 30m, 2h, 6h, 24h, give up 72 hours after the event, and then dead-letter with an email.
What to read next
- Payments — every field, partial captures, refunds and the status transitions
- Errors — the envelope, every type, and which declines are safe to retry
- Idempotency — what to do when a request times out
- Sandbox and test cards — the other eight cards and the per-request overrides
- Checkout sessions — if you would rather not build a card form at all