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
| Event | Meaning |
|---|---|
payment.settled | Money moved. Fulfil here. |
payment.authorized | Customer paid; not yet settled |
payment.awaiting_customer | Redirect issued; customer has not paid yet |
payment.failed | Declined or errored |
payment.cancelled | Abandoned or cancelled |
payment.expired | Never completed |
refund.settled | Refund reached the customer |
refund.failed | Refund was refused; the money stays with you |
payout.submitted | Payout accepted, queued at the bank |
payout.sent | Payout handed to the scheme |
payout.completed | Payout reached the recipient |
payout.failed | Payout was rejected; the money stays with you |
payout.returned | Payout bounced back |
crypto.payment.received | Crypto deposit confirmed on-chain. Fulfil here. |
crypto.payment.underpaid | Deposit confirmed, but less than the amount due |
crypto.payment.held | Deposit held for review — source failed sanctions screening. Do not fulfil. |
crypto.payment.expired | No deposit before the window closed |
crypto.settlement.completed | We paid your balance out to your registered wallet |
crypto.settlement.failed | A 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.