Skip to content
Sandbox open: magic test cards, hosted checkout and signed webhooks · 55 payment methods in the catalogue, cards to crypto · Built and hosted in the EEA · Read the API reference at /developers

Sandbox open: magic test cards, hosted checkout and signed webhooks

Documentation

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.

ParameterRequiredNotes
urlyesAbsolute https URL, ≤ 2048 characters.
descriptionno≤ 200 characters. For your own records.
enabled_eventsnoUp 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.created

Dispute, 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>
  • t is 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.
  • v1 is hex(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
}