Accept Cleo at your checkout
Let your customers buy now and pay later. Cleo handles identity, the credit decision, the bank connection and the instalment plan — you make two API calls.
Quickstart
The whole integration is a redirect and a confirmation. Cleo hosts the checkout, so the shopper's identity documents, bank credentials and credit data never touch your servers.
Create a session
Server-to-server with your secret key: send the order amount and your return URLs. Cleo returns a session_id and a checkout_url.
Redirect the shopper
Send the browser to the checkout_url we returned. Don't build it by hand.
The shopper completes Cleo
RUT → WhatsApp OTP → bank connection → credit decision → instalment plan → direct-debit mandate. All inside Cleo.
Confirm the result
You get a server-to-server callback, and you can always read the session status. Confirm before you fulfil.
149990 means $149.990 CLP.Auth & environments
Every call carries your secret key as a bearer token. Your merchant_id is derived from the key — you never send it, and you cannot act on another merchant's behalf.
Authorization: Bearer sk_live_<your-key> Content-Type: application/json
Environments
| Environment | Base URL | Key |
|---|---|---|
| Production | https://api-bnpl.cleo.cl | sk_live_… |
| Sandbox — integrate here first | https://sandbox-api-bnpl.cleo.cl | sk_test_… |
| Staging / Dev (Cleo internal) | staging-api-bnpl.cleo.cl · dev-api-bnpl.cleo.cl | sk_test_… |
Each environment has its own database — nothing you do in sandbox touches production. The API refuses a key from the wrong environment rather than doing something surprising with it:
{"error":"wrong_environment","detail":"This host serves test keys; that key is live. Use the production base URL."}
The only difference between sandbox and production is the base URL and the key.
checkout_url).
Your secret key must never reach a browser.
The key is issued when Cleo approves your merchant application and is shown once. We store it hashed — we cannot recover it, only replace it. It is verified on every call, so a revoked key stops working on the very next request.
| Response | Meaning |
|---|---|
401 merchant_key_required | No Authorization header. |
401 invalid_or_revoked_key | Wrong key, or one that has been revoked. |
401 wrong_environment | Test key against production, or the reverse. |
409 merchant_not_linked | Key is valid but the account isn't finished — contact Cleo. |
429 rate_limited | More than 120 requests per minute. |
Create a session
Body
| Field | Required | Notes |
|---|---|---|
amount | yes | Integer CLP, between 1,000 and 10,000,000. This is what the shopper is charged; they cannot change it. |
currency | CLP only (default). | |
merchant_order_id | recommended | Your order reference. Enables the idempotency and double-payment guards below, and is echoed in the callback. |
success_url / failure_url | recommended | Where the shopper's browser goes when they finish. |
callback_url / callback_failure_url | Where we POST the result server-side. Falls back to the defaults configured on your merchant account. | |
callback_method / callback_failure_method | POST (default) or GET. | |
merchant_session_id · merchant_customer_id | Your own references, echoed back in the callback. | |
expires_in_minutes | 5–1440. Default 60. | |
sign_callback | If true, outgoing callbacks for this session carry an X-Cleo-Signature: sha256=… header — see Verifying the callback signature. | |
payment_method | Pins the instalment plan and skips selection. One of PAY_IN_14_DAYS, PAY_IN_30_DAYS, PAY_IN_3_PARTS, PAY_IN_6_PARTS, PAY_IN_9_PARTS, PAY_IN_12_PARTS, ETPAY. |
All URLs must be public https. URLs pointing at localhost, private ranges, link-local addresses, or containing credentials are rejected.
curl -X POST https://api-bnpl.cleo.cl/v1/sessions \ -H "Authorization: Bearer $CLEO_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": 149990, "currency": "CLP", "merchant_order_id": "ORD-10432", "success_url": "https://shop.example.com/checkout/thanks?order=10432", "failure_url": "https://shop.example.com/checkout/error?order=10432", "callback_url": "https://shop.example.com/api/cleo/webhook" }'
<?php $ch = curl_init("https://api-bnpl.cleo.cl/v1/sessions"); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("CLEO_SECRET_KEY"), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => json_encode([ "amount" => 149990, "merchant_order_id" => "ORD-10432", "success_url" => "https://shop.example.com/checkout/thanks?order=10432", "failure_url" => "https://shop.example.com/checkout/error?order=10432", "callback_url" => "https://shop.example.com/api/cleo/webhook", ]), CURLOPT_RETURNTRANSFER => true, ]); $session = json_decode(curl_exec($ch), true); header("Location: " . $session["checkout_url"]);
import os, requests r = requests.post( "https://api-bnpl.cleo.cl/v1/sessions", headers={"Authorization": f"Bearer {os.environ['CLEO_SECRET_KEY']}"}, json={ "amount": 149990, "merchant_order_id": "ORD-10432", "success_url": "https://shop.example.com/checkout/thanks?order=10432", "failure_url": "https://shop.example.com/checkout/error?order=10432", "callback_url": "https://shop.example.com/api/cleo/webhook", }, ) session = r.json() checkout_url = session["checkout_url"] # redirect the shopper here
const res = await fetch("https://api-bnpl.cleo.cl/v1/sessions", { method: "POST", headers: { authorization: `Bearer ${process.env.CLEO_SECRET_KEY}`, "content-type": "application/json", }, body: JSON.stringify({ amount: 149990, merchant_order_id: "ORD-10432", success_url: "https://shop.example.com/checkout/thanks?order=10432", failure_url: "https://shop.example.com/checkout/error?order=10432", callback_url: "https://shop.example.com/api/cleo/webhook", }), }); const session = await res.json(); return Response.redirect(session.checkout_url, 303);
Response · 201
{
"session_id": "b26d5129-146e-459b-9411-3a3800874305",
"checkout_url": "https://api-bnpl.cleo.cl/checkout/start/b26d5129-146e-459b-9411-3a3800874305",
"expires_at": "2026-07-23T15:26:06.900Z",
"amount": 149990,
"currency": "CLP",
"status": "RUT",
"reused": false
}
checkout_url exactly as returned. In sandbox it points at the sandbox host; building it by hand is the fastest way to send a shopper to the wrong environment.Retries and double payments
Creating a session again with the same merchant_order_id:
- returns the existing session (
200,"reused": true) while it is still open — a retry never creates a second checkout; - returns
409 order_already_paidonce that order has been paid, so a stale tab or a duplicated request can't charge the shopper twice.
Merchants who genuinely need repeated references can be flagged to allow them.
Redirect the shopper
Send the browser to the checkout_url you received:
That page shows your store name, the order reference and the amount, then runs the Cleo flow: Chilean RUT verification, WhatsApp OTP, a read-only bank connection, the credit decision, the instalment plan and the direct-debit mandate. The amount is set by the merchant and the shopper cannot change it.
When they finish, the shopper gets a "Volver a {your store}" button pointing at your success_url.
Callback (result)
When the checkout finishes we POST the result to your callback_url:
{
"createdAt": "2026-07-23T15:40:11.000Z",
"callbackId": 8123,
"version": "v1",
"event": "CHECKOUT_SUCCEEDED",
"payload": {
"sessionId": "b26d5129-…",
"merchantId": "your-login",
"merchantSessionId": null,
"merchantCustomerId": null,
"merchantOrderId": "ORD-10432",
"status": "OK",
"currency": "CLP",
"amount": 149990,
"invoiceNumber": "CLO-4821-1",
"customer": {
"ssn": "12345678-9",
"email": "…",
"phone": "…",
"name": { "first": "…", "last": "…", "org": null },
"address": { "street": "…", "region": "…", "country": "CL" }
}
}
}
event is CHECKOUT_SUCCEEDED (status: "OK") or CHECKOUT_FAILED (status: "ERROR", e.g. credit declined). With callback_method: "GET" the same JSON arrives base64-encoded in a SWEETPAY_DATA query parameter.
At-least-once delivery
We try twice immediately, then retry on a backoff — 1 min, 5 min, 30 min, 2 h, 6 h, i.e. 6 attempts over roughly 8.5 hours — until your endpoint answers 2xx. Any 2xx counts as delivered; a 45s timeout counts as a failure.
Because a retry re-sends the same payload byte for byte, callbackId is stable across attempts:
- Make your handler idempotent — dedupe on
callbackId. If your endpoint processed the request but the 2xx never reached us, you will be called again. - Answer fast (ack, then do the work). We treat a slow endpoint as a failure.
If all attempts fail we stop and log it; the order is still paid, and the status endpoint remains the source of truth.
sign_callback: true on session creation and
retried callbacks and the expiration callback carry an
X-Cleo-Signature: sha256=… header (see below). The very first delivery attempt —
the one sent inline the moment the checkout finishes — does not yet sign, because it's sent from a
different service. Until that's closed, confirm every callback against the
status endpoint with your secret key before fulfilling an order, signed or
not — that call is authenticated and authoritative, and it also covers the case where a callback
never arrives.
Verifying the callback signature
When X-Cleo-Signature is present, it is sha256=<hex> where <hex>
is HMAC-SHA256(raw request body, your webhook_signing_secret) — the same whsec_…
shown to you when your key was issued (ask Cleo if you need it resent). Recompute it over the
exact raw bytes you received (before any JSON re-serialization, since key order or whitespace
changes the hash) and compare with a constant-time comparison:
const crypto = require("node:crypto"); function verify(rawBody, header, secret) { const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); return header?.length === expected.length && crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected)); }
Read the status
curl https://api-bnpl.cleo.cl/v1/sessions/b26d5129-146e-459b-9411-3a3800874305 \ -H "Authorization: Bearer $CLEO_SECRET_KEY"
const s = await (await fetch( `https://api-bnpl.cleo.cl/v1/sessions/${sessionId}`, { headers: { authorization: `Bearer ${process.env.CLEO_SECRET_KEY}` } }, )).json(); if (s.paid) await fulfil(s.merchant_order_id);
Response · 200
{
"session_id": "b26d5129-…",
"status": "CONFIRMED",
"paid": true,
"expired": false,
"expires_at": "2026-07-23T15:26:06.900Z",
"amount": 149990,
"currency": "CLP",
"merchant_order_id": "ORD-10432",
"cancelled": false,
"callback": { "sent_at": "2026-07-23T15:40:11.000Z", "delivered": true },
"installments": [
{ "invoice_number": "CLO-4821-1", "amount": 52497, "due_at": "…", "number": 1, "payment_status": "PENDING" }
]
}
paid is the field to key on. A session you didn't create returns 404 — you can only read your own.
Cancel an order
// cancel-partial { "amount": 15000 }
Cancels (fully or by a given amount) the order behind that session and creates a refund
for anything already paid over the new total — same engine and rules as when Cleo cancels it for you:
an order already cancelled returns 409 already_cancelled, one with an instalment mid-payment
returns 409 invoices_in_payment_process, and a cancel-partial amount that isn't
strictly less than the order's cancellable remainder returns 400 amount_too_high (or
400 bad_amount if it isn't a positive number). A session you didn't create returns
404 session_not_found — you can only cancel your own, same as reading status.
Response · 200
{ "ok": true, "cancelledInvoiceNumbers": ["CLO-4821-1"], "refundCreated": true, "amountRefunded": 31497 }
You'll also get an ORDER_CANCELLED callback (see Callback) at your
callback_url — discriminate on event, since it's the same URL that receives
CHECKOUT_SUCCEEDED/CHECKOUT_FAILED.
Session statuses
status tracks the shopper's journey. To decide what to do with the order, paid is enough; the intermediate statuses are for diagnostics.
| Status | Meaning | Terminal? |
|---|---|---|
RUT · PHONE · PIN · CUSTOMER_DETAILS · EMAIL_PIN | Identity and contact — the shopper is working through it. | no |
BANK_ONBOARDING · BANK_ONBOARDING_PENDING · CHECKING_CUSTOMER_DATA | Bank connection and credit evaluation in progress. | no |
PAYMENT_METHOD · SUBSCRIPTION_INTENT_INIT | Choosing the instalment plan and signing the mandate. | no |
CONFIRMED | Approved and mandate signed. paid: true — fulfil the order. | yes |
DENIED | The credit evaluation did not approve. | yes |
ERROR · FAILED | The flow could not be completed. | yes |
A session that passes its expires_at without being confirmed comes back with "expired": true. The shopper can start over with a new session.
Errors
| Status | Error | Fix |
|---|---|---|
| 400 | invalid_amount | Integer CLP within the allowed range. |
| 400 | unsupported_currency | Only CLP. |
| 400 | *_must_be_https · *_must_be_public · *_invalid | Use a public https URL. |
| 401 | merchant_key_required · invalid_or_revoked_key · wrong_environment | Check the key and the environment's base URL. |
| 403 | merchant_inactive | Account disabled — contact Cleo. |
| 404 | session_not_found | That session_id doesn't exist, or isn't yours. |
| 409 | order_already_paid | That merchant_order_id is already paid. |
| 409 | merchant_not_linked · merchant_not_found | Account setup incomplete — contact Cleo. |
| 400 | invalid_payment_method | One of the values listed in Create a session. |
| 400 | bad_amount | cancel-partial's amount must be a positive number. |
| 400 | amount_too_high | cancel-partial's amount must be strictly less than the order's cancellable remainder. |
| 409 | already_cancelled | That order was already cancelled. |
| 409 | invoices_in_payment_process | An instalment is mid-payment; retry once it settles. |
| 503 | feature_disabled | Cancellation isn't available yet — see Cancel an order. |
| 429 | rate_limited | Back off; the limit is 120 req/min. |
| 503 | temporarily_unavailable | Retry with backoff. |
Testing in sandbox
Integrate against https://sandbox-api-bnpl.cleo.cl with an sk_test_… key first. These values complete a purchase end-to-end without a real WhatsApp message, bank or credit lookup:
| Field | Test value |
|---|---|
| RUT | 11.111.111-1 — the simulation RUT; returns a stubbed person and bank profile |
| Phone | +56911111111 |
| WhatsApp / email PIN | 1111 |
CONFIRMED path, and raise them to see DENIED.EVALUATED_AMOUNT_TOO_HIGH when the order amount plus the shopper's outstanding debt exceeds $1.000.000 CLP. Note the asymmetry: session creation accepts amounts up to $10.000.000, but the checkout applies that cap at evaluation time — an order above the limit is created fine and ends in DENIED.Going live
Once your sandbox integration is ready:
Request production credentials
Cleo approves your merchant and issues an sk_live_… key. It is shown once — put it in your secret manager, never in the repository.
Swap the base URL and the key
sandbox-api-bnpl.cleo.cl → api-bnpl.cleo.cl. Nothing else in your code changes.
Register your production URLs
Your production callback_url, success_url and failure_url must be public https and reachable from the internet.
Verify before fulfilling
Confirm every callback against GET /v1/sessions/{id} with your secret key.