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.

Buy now, pay later Chile · CLP Hosted checkout — no PCI scope

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.

1

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.

2

Redirect the shopper

Send the browser to the checkout_url we returned. Don't build it by hand.

3

The shopper completes Cleo

RUT → WhatsApp OTP → bank connection → credit decision → instalment plan → direct-debit mandate. All inside Cleo.

4

Confirm the result

You get a server-to-server callback, and you can always read the session status. Confirm before you fulfil.

Amounts are integers in CLP — no decimals. 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

EnvironmentBase URLKey
Productionhttps://api-bnpl.cleo.clsk_live_…
Sandbox — integrate here firsthttps://sandbox-api-bnpl.cleo.clsk_test_…
Staging / Dev (Cleo internal)staging-api-bnpl.cleo.cl · dev-api-bnpl.cleo.clsk_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.

One host, two jobs. The same host serves the API (your server, with the secret key) and the hosted checkout (the shopper's browser, at the 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.

ResponseMeaning
401 merchant_key_requiredNo Authorization header.
401 invalid_or_revoked_keyWrong key, or one that has been revoked.
401 wrong_environmentTest key against production, or the reverse.
409 merchant_not_linkedKey is valid but the account isn't finished — contact Cleo.
429 rate_limitedMore than 120 requests per minute.

Create a session

POST/v1/sessions

Body

FieldRequiredNotes
amountyesInteger CLP, between 1,000 and 10,000,000. This is what the shopper is charged; they cannot change it.
currencyCLP only (default).
merchant_order_idrecommendedYour order reference. Enables the idempotency and double-payment guards below, and is echoed in the callback.
success_url / failure_urlrecommendedWhere the shopper's browser goes when they finish.
callback_url / callback_failure_urlWhere we POST the result server-side. Falls back to the defaults configured on your merchant account.
callback_method / callback_failure_methodPOST (default) or GET.
merchant_session_id · merchant_customer_idYour own references, echoed back in the callback.
expires_in_minutes5–1440. Default 60.
sign_callbackIf true, outgoing callbacks for this session carry an X-Cleo-Signature: sha256=… header — see Verifying the callback signature.
payment_methodPins 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"
      }'

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
}
Always use the 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_paid once 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:

GEThttps://api-bnpl.cleo.cl/checkout/start/{session_id}

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.

The redirect is not a payment confirmation. It's navigation, and the shopper may close the tab first. Fulfil on the callback or the status endpoint, never on the redirect alone.

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.

Signing is partial today. Set 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

GET/v1/sessions/{session_id}
curl https://api-bnpl.cleo.cl/v1/sessions/b26d5129-146e-459b-9411-3a3800874305 \
  -H "Authorization: Bearer $CLEO_SECRET_KEY"

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

Coming soon — available from September 1, 2026. This section describes the contract so you can prepare your integration ahead of time; the endpoints don't respond yet in production or sandbox.
POST/v1/sessions/{session_id}/cancel
POST/v1/sessions/{session_id}/cancel-partial
// 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.

StatusMeaningTerminal?
RUT · PHONE · PIN · CUSTOMER_DETAILS · EMAIL_PINIdentity and contact — the shopper is working through it.no
BANK_ONBOARDING · BANK_ONBOARDING_PENDING · CHECKING_CUSTOMER_DATABank connection and credit evaluation in progress.no
PAYMENT_METHOD · SUBSCRIPTION_INTENT_INITChoosing the instalment plan and signing the mandate.no
CONFIRMEDApproved and mandate signed. paid: truefulfil the order.yes
DENIEDThe credit evaluation did not approve.yes
ERROR · FAILEDThe 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

StatusErrorFix
400invalid_amountInteger CLP within the allowed range.
400unsupported_currencyOnly CLP.
400*_must_be_https · *_must_be_public · *_invalidUse a public https URL.
401merchant_key_required · invalid_or_revoked_key · wrong_environmentCheck the key and the environment's base URL.
403merchant_inactiveAccount disabled — contact Cleo.
404session_not_foundThat session_id doesn't exist, or isn't yours.
409order_already_paidThat merchant_order_id is already paid.
409merchant_not_linked · merchant_not_foundAccount setup incomplete — contact Cleo.
400invalid_payment_methodOne of the values listed in Create a session.
400bad_amountcancel-partial's amount must be a positive number.
400amount_too_highcancel-partial's amount must be strictly less than the order's cancellable remainder.
409already_cancelledThat order was already cancelled.
409invoices_in_payment_processAn instalment is mid-payment; retry once it settles.
503feature_disabledCancellation isn't available yet — see Cancel an order.
429rate_limitedBack off; the limit is 120 req/min.
503temporarily_unavailableRetry 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:

FieldTest value
RUT11.111.111-1 — the simulation RUT; returns a stubbed person and bank profile
Phone+56911111111
WhatsApp / email PIN1111
The simulated profile reports $500.000 CLP of monthly income. Keep test orders well below the approved limit to see the CONFIRMED path, and raise them to see DENIED.
The evaluation declines with 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:

1

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.

2

Swap the base URL and the key

sandbox-api-bnpl.cleo.clapi-bnpl.cleo.cl. Nothing else in your code changes.

3

Register your production URLs

Your production callback_url, success_url and failure_url must be public https and reachable from the internet.

4

Verify before fulfilling

Confirm every callback against GET /v1/sessions/{id} with your secret key.