CleonPay

Webhooks

How you learn a payment succeeded. The only authoritative signal.

We POST a signed JSON body to the URL you give us, and retry for 24 hours across 12 attempts with exponential backoff. Return any 2xx to acknowledge.

Endpoints are managed in your dashboard: add one per environment, read its signing secret, and send yourself a test event to prove your handler end to end.

POST https://your-server.example.com/webhooks/cleonpay
Content-Type: application/json
CleonPay-Signature: t=1755789326,v1=5257a869e7ecebeda32affa62cdca3fa...
CleonPay-Event-Type: payment.settled

{
  "id": "evt_01H...",
  "type": "payment.settled",
  "created": "2026-08-21T13:15:26.438Z",
  "livemode": true,
  "data": { "payment_id": "3743881d-0fce-416c-b3de-8bc3f5d414b1" }
}

Endpoints are per environment: sandbox events only ever reach your sandbox endpoint, and live events your live one. livemode repeats that in the body so a misconfigured handler can double-check.

Events

EventMeaning
payment.settledMoney moved. Fulfil here.
payment.authorizedCustomer paid; not yet settled
payment.awaiting_customerRedirect issued; customer has not paid yet
payment.failedDeclined or errored
payment.cancelledAbandoned or cancelled
payment.expiredNever completed
refund.settledRefund reached the customer
refund.failedRefund was refused; the money stays with you
payout.submittedPayout accepted, queued at the bank
payout.sentPayout handed to the scheme
payout.completedPayout reached the recipient
payout.failedPayout was rejected; the money stays with you
payout.returnedPayout bounced back
crypto.payment.receivedCrypto deposit confirmed on-chain. Fulfil here.
crypto.payment.underpaidDeposit confirmed, but less than the amount due
crypto.payment.heldDeposit held for review — source failed sanctions screening. Do not fulfil.
crypto.payment.expiredNo deposit before the window closed
crypto.settlement.completedWe paid your balance out to your registered wallet
crypto.settlement.failedA settlement to your wallet failed; the balance stays with you
Crypto events carry crypto_payment_id (or crypto_settlement_id for settlements), not payment_id — e.g. "data": { "crypto_payment_id": "…" }. Fetch it with GET /v1/crypto/payments/{id} or GET /v1/crypto/settlements. See Crypto payments. They only fire when crypto is enabled on your account.

Verifying the signature

Verify before you act. An endpoint that trusts any POST can be told a payment succeeded by anyone who finds the URL.
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(secret, header, rawBody, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.split('=').map((s) => s.trim())),
  );
  if (!parts.t || !parts.v1) return false;

  // Reject anything old, so a captured delivery cannot be replayed later.
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) return false;

  const expected = createHmac('sha256', secret)
    .update(parts.t + '.' + rawBody)
    .digest('hex');

  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1, 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}
Use the raw body. Verify against the exact bytes received. Parsing to JSON and re-serialising changes whitespace and key order, and the signature will never match.

Duplicates

A retry after your server timed out means the same event arrives twice. Deduplicate on the event id and treat a repeat as a no-op.