API reference
Webhooks
Register endpoints, subscribe to events, roll a secret with no downtime, and verify the signature. Plus the retry schedule and the dead letter.
Updated
A payment's status changes without you asking: an async rail settles, a payer finishes a challenge, an authorisation expires, a refund succeeds. Webhooks are how you hear about it. Polling is not a substitute — fulfil on the event, and use reads to confirm.
Register an endpoint
POST /v1/webhook-endpoints → 201.
| Parameter | Required | Notes |
|---|---|---|
url | yes | Absolute https URL, ≤ 2048 characters. |
description | no | ≤ 200 characters. For your own records. |
enabled_events | no | Up to 64 patterns: exact types, a payment.* family, or *. Omitted means ["*"]. |
curl https://cleared-pay.com/api/v1/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": "Production receiver",
"enabled_events": ["payment.captured", "payment.declined", "refund.*"]
}'{
"id": "whe_01JBXQ8Q2T6V4X9Z3CF7HJ1LNP",
"object": "webhook_endpoint",
"url": "https://your-app.example/webhooks/cleared-pay",
"description": "Production receiver",
"enabled_events": ["payment.captured", "payment.declined", "refund.*"],
"status": "enabled",
"secret": "cp_whsec_…",
"secret_last4": "9f2a",
"previous_secret_expires_at": null,
"livemode": false,
"created": "2026-08-19T10:16:00Z"
}secret appears on create and on roll-secret, and nowhere else — store it before you close the terminal. Afterwards only secret_last4 is returned.
URL rules
The URL must be https, must carry no credentials, and must not resolve to a loopback, private, link-local, CGNAT, multicast or cloud-metadata address — in IPv4 or in any of the IPv6 forms. The check runs at creation and again before every delivery, so a hostname that later starts resolving to an internal address stops being delivered to rather than becoming a way into our network.
Rejections are 400 with invalid_url, https_required, private_address or unresolvable_host. For local development, use a tunnel that gives you a public https URL.
At most 16 enabled endpoints per environment; the seventeenth is 422 endpoint_limit_reached.
List, retrieve, delete
curl https://cleared-pay.com/api/v1/webhook-endpoints \
-H "Authorization: Bearer $CLEARED_SECRET_KEY"
curl https://cleared-pay.com/api/v1/webhook-endpoints/whe_01JBXQ8Q2T6V4X9Z3CF7HJ1LNP \
-H "Authorization: Bearer $CLEARED_SECRET_KEY"
curl -X DELETE https://cleared-pay.com/api/v1/webhook-endpoints/whe_01JBXQ8Q2T6V4X9Z3CF7HJ1LNP \
-H "Authorization: Bearer $CLEARED_SECRET_KEY"DELETE disables the endpoint and hides it from the list; it does not erase anything. The attempt history stays, because "did we ever deliver that event" is a question you will eventually need answered. The response is { "id": …, "object": "webhook_endpoint", "deleted": true }.
Roll a secret
POST /v1/webhook-endpoints/:id/roll-secret → 200 with the endpoint and a new secret, shown once.
curl -X POST https://cleared-pay.com/api/v1/webhook-endpoints/whe_01JBXQ8Q2T6V4X9Z3CF7HJ1LNP/roll-secret \
-H "Authorization: Bearer $CLEARED_SECRET_KEY"For 24 hours afterwards, every delivery carries one v1= signature per active secret — the new one and the old one — and previous_secret_expires_at says when that stops. So the order is: roll, deploy the new secret, and let the old one lapse. Nothing fails in between, as long as your receiver accepts any matching signature rather than only the first.
Events
Subscribe to what you need. A family pattern keeps working when a new type is added to it.
payment.created payment.requires_action payment.processing
payment.authorized payment.captured payment.partially_captured
payment.capture_failed payment.declined payment.failed
payment.canceled payment.expired
refund.created refund.succeeded refund.failed
checkout_session.completed checkout_session.expired
dispute.created dispute.evidence_required dispute.won
dispute.lost dispute.closed
payout.created payout.paid payout.failed
customer.created customer.payment_method.attached
customer.payment_method.detached
payment_link.createdDispute, payout, customer and payment-link resources are reserved for a later version; their event types are already defined so a receiver written today needs no change when they arrive.
The envelope
{
"id": "evt_01JBXQ9R4V8X6Z2B5DG9KL3NQS",
"object": "event",
"type": "payment.captured",
"api_version": "2026-08-19",
"livemode": false,
"created": "2026-08-19T10:15:01Z",
"data": {
"object": { "id": "pay_01JBXQ2M4K7T9V3ZC8HR5N6PWD", "object": "payment", "status": "captured" },
"previous_attributes": { "status": "authorized" }
}
}data.object is the full resource as it was at the time of the event — the same shape the API returns. previous_attributes carries only what changed. The delivery request also carries Cleared-Event-Id, Cleared-Event-Type and an Idempotency-Key, so you can deduplicate on a header without parsing the body.
Reading events back
curl -G https://cleared-pay.com/api/v1/events \
-H "Authorization: Bearer $CLEARED_SECRET_KEY" \
-d "type=payment.captured" -d "limit=25"
curl https://cleared-pay.com/api/v1/events/evt_01JBXQ9R4V8X6Z2B5DG9KL3NQS \
-H "Authorization: Bearer $CLEARED_SECRET_KEY"GET /v1/events filters on type and resource and paginates like any list. GET /v1/events/:id adds an attempts array with each delivery: attempt number, status, http_status, the first 2 KB of the response_body, duration_ms, next_retry_at and attempted_at. That is usually enough to see why a delivery failed without adding logging on your side.
To try again after you have fixed the receiver:
curl https://cleared-pay.com/api/v1/events/evt_01JBXQ9R4V8X6Z2B5DG9KL3NQS/resend \
-H "Authorization: Bearer $CLEARED_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"endpoint":"whe_01JBXQ8Q2T6V4X9Z3CF7HJ1LNP"}'Omit endpoint to resend to every subscribed endpoint. A resend continues the attempt numbering rather than starting over, so the history stays readable.
Delivery, retries and the dead letter
A delivery is a POST with a 10-second timeout. Anything in the 2xx range counts as delivered; everything else is a failure, including a redirect.
Retries run at 10s, 1m, 5m, 30m, 2h, 6h, 24h — the first attempt plus seven retries. Retrying stops 72 hours after the event was created, whichever comes first, and the event is then dead_lettered with an email to the account owner. Every attempt is recorded. A sweeper re-enqueues any event that somehow has no attempt, every five minutes, so a lost job never becomes a lost event.
What this means for your receiver:
- Return 2xx fast. Acknowledge, then work. Anything over 10 seconds is a failed delivery however well it ended.
- Deduplicate on
event.id. Retries are expected, and so is the occasional duplicate of an event you already handled. - Do not rely on order. Events are not ordered across resources, and a retried event can arrive after a newer one.
- Ignore what you do not know. New event types and new fields ship without a version bump.
The signature scheme
Every delivery carries:
Cleared-Signature: t=1755600000,v1=<hex hmac-sha256>tis unix seconds at signing time.- The signed payload is
t, a literal., and the raw request body — the exact bytes, before any parsing or re-serialisation. v1ishex(HMAC_SHA256(endpoint_secret, signed_payload)).- The tolerance is ±300 seconds. Reject anything outside it: that is what stops a captured delivery being replayed at you later.
- During the 24 hours after a roll the header carries one
v1=per active secret. Accept any that matches.
Two mistakes are worth naming, because both silently pass in testing and fail in production. Parsing the body before verifying re-serialises it and changes the bytes, so the HMAC will not match — read the raw body first. And comparing with == leaks timing; compare in constant time, after checking the lengths, because several constant-time helpers throw on a length mismatch.
Verify a signature
The three published receivers. Each reads the raw body, checks the 300-second tolerance, accepts any active signature so a secret roll cannot break delivery, compares in constant time with the lengths checked first, and returns 2xx before doing any work.
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
}