Toki · API
Charge from your system. Let them pay with Toki.
Your POS, your online store or your ERP create a charge with one call. Toki returns a payment link; your customer opens it, pays from their wallet and comes back to your site. The money reaches your Toki account instantly.
https://api.toki.lat/v1Create a credentialHow it works
- 1. Your system calls
POST /v1/cobroswith the amount. - 2. Toki responds with a charge and a
url_checkout. - 3. You send your customer there, or embed it in your own page.
- 4. They pay with the Toki app and we return them to your
url_retorno. - 5. We notify you by webhook (or you poll the charge status).
Your credential requests money; it never takes it. No call to this API moves money: the charge is confirmed by the person on their phone, from their own session and their own wallet. If your key leaked, whoever has it could generate charges in your name — annoying, and revocable in one click — but could not take a peso from anyone.
Authentication
Every call carries your credential in the Authorization header. The token has the form <key_id>.<secret>; you get it whole when you create the credential in your panel, and only then.
Authorization: Bearer tk_9f2c8ab1de4057.9d31...c0In your panel you can also restrict which IPs the credential is accepted from. If you set it, a call from any other address is rejected even with the right secret. That is the difference between a leaked key that works from anywhere and one that is useless outside your server.
Limit: 120 calls per minute per credential.
Currency
The currency of everything you charge is the one of your Toki wallet. It is not chosen per charge: a merchant charges in one currency, theirs.
Amounts always go in the smallest unit of that currency, the standard for any payment gateway. Chilean pesos have no subdivision, so 15990 is fifteen thousand nine hundred ninety pesos. In a currency with cents, 1599 is 15.99.
/v1/comercioTells you which currency you charge in, your merchant name, and whether the credential is test or live. Use it to validate your setup without creating a throwaway charge.
{ "comercio": { "nombre": "My Store", "moneda": "CLP", "modo": "prueba" } }If your store charges in a different currency than your wallet, do not integrate yet. Toki does not convert: it would charge the number you send as if it were in your currency. Talk to us first.
Create a charge
/v1/cobros| Field | Type | What it is |
|---|---|---|
| monto | integer, required | In the smallest unit of your currency. CLP has no subdivision: 15990 is fifteen thousand nine hundred ninety pesos. In a currency with cents, 1599 is 15.99. |
| concepto | text | What your customer sees. "Receipt 4471", "Table 12". |
| referencia_externa | text | Your identifier. It also makes the charge idempotent: retrying with the same reference returns the existing charge instead of creating another. |
| url_retorno | https | Where your customer goes after paying. |
| url_cancelacion | https | Where they go if they back out. |
| expira_minutos | integer | Between 1 and 1440. Defaults to 15. |
| metadata | object | Anything you want to keep. We hand it back untouched in the webhook. |
curl -X POST https://api.toki.lat/v1/cobros \
-H "Authorization: Bearer $TOKI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"monto": 15990,
"concepto": "Receipt 4471",
"referencia_externa": "b-4471",
"url_retorno": "https://mystore.com/thanks",
"url_cancelacion": "https://mystore.com/cart",
"metadata": { "register": "3" }
}'{
"cobro": {
"id": "9cd827ca-d10d-4768-8627-265d875f1cd2",
"monto": 15990,
"moneda": "CLP",
"concepto": "Receipt 4471",
"estado": "pending",
"referencia_externa": "b-4471",
"metadata": { "register": "3" },
"expira_en": "2026-08-25T15:44:57Z",
"creado_en": "2026-08-25T15:29:57Z",
"pagado_en": null,
"url_checkout": "https://toki.lat/pagar/9cd827ca...",
"qr_svg": "https://api.toki.lat/v1/cobros/9cd827ca.../qr",
"deeplink": "toki://pay/9cd827ca..."
}
}Use `url_checkout`: it is the payment page we host. qr_svg and deeplink are there for anyone who wants to build their own screen — read the next section before deciding that.
The payment page
Sending your customer to url_checkout is the recommended way to charge, and not only for convenience.
A QR cannot be scanned by the same phone that displays it. If your customer is buying from your store on their mobile — the most common case — a QR image is useless to them. Our page detects this: on a phone it offers to open the app directly, on desktop it shows the QR.
It also follows the status on its own, shows how long until the charge expires, and returns the customer to your site when it is done. And because the page is ours, we can improve the flow or add payment methods without you deploying anything again.
Embedding it in your site
If you would rather your customer never leaves your page, load it in an iframe. We tell you the result with postMessage, so you do not have to poll from the browser.
<iframe
src="https://toki.lat/pagar/9cd827ca..."
style="width:100%;max-width:420px;height:620px;border:0"
allow="clipboard-write"></iframe>
<script>
window.addEventListener('message', (e) => {
if (e.origin !== 'https://toki.lat') return; // ALWAYS check the origin
if (e.data?.fuente !== 'toki') return;
if (e.data.evento === 'cobro.paid') {
// Confirm against YOUR server before fulfilling: a browser message can be
// forged by anyone. This is for reacting on screen.
showThankYou();
}
});
</script>The `postMessage` is for the interface, not for deciding. Before handing over a product, confirm with GET /v1/cobros/{id} from your server or wait for the webhook. Anyone can send your page a message; nobody can forge our signed response.
The QR
/v1/cobros/{id}/qrReturns the code as SVG, so it looks sharp both on a thermal receipt and on a register screen. It is the only route that does not ask for a credential: it gets printed on receipts, where there is nowhere to put a header. What it exposes is the same id already encoded in the code, and with that id you can only pay.
Use it when the payment happens in front of you — a register, a printed receipt — where your customer has their own phone to scan with. To charge online, use the payment page.
<img src="https://api.toki.lat/v1/cobros/{id}/qr" alt="Pay with Toki" />Check a charge
/v1/cobros/{id}Returns the charge with its estado: pending, paid, cancelled or expired. It is the fallback if you cannot receive webhooks — a register behind a closed network integrates with this alone.
curl https://api.toki.lat/v1/cobros/9cd827ca-d10d-4768-8627-265d875f1cd2 \
-H "Authorization: Bearer $TOKI_API_KEY"Cancel a charge
/v1/cobros/{id}/anularOnly while it is pending. An already paid charge is not cancelled here: giving money back is a refund, with its own flow. Cancelling a paid charge would leave the books saying one thing and the charge another.
Services and subscriptions
A service is something people subscribe to: a plan, a membership, a monthly fee. You publish it via API and generate a link for someone to subscribe. From then on, the charge repeats on its own.
Nobody gets subscribed without confirming. The link activates nothing: the person sees how much and how often they will be charged, and confirms it in their app. Afterwards it shows up in Money → Subscriptions, where they can cancel without going through you.
Publish a service
/v1/servicios| Field | Type | What it is |
|---|---|---|
| nombre | text, required | What your customer sees. "Monthly plan", "Member fee". |
| precio | integer, required | In the smallest unit of your currency, same as `monto`. |
| descripcion | text | What it includes. |
| recurrente | boolean | Defaults to true. With false it stays published but takes no subscriptions: to charge it once use /v1/cobros. |
| cada | integer | Defaults to 1. |
| unidad | text | day, week, month, semester or year. Defaults to month. |
| dia_de_cobro | integer 1–31 | The day of the month everyone is charged. Only with unit month, semester or year. Without it, each person is charged on the day they subscribed. |
| dia_de_semana | integer 0–6 | Only with unit week. 0 is Sunday. |
| politica_mes_corto | text | What to do when the day does not exist in a month: last, first_next or skip. Only with dia_de_cobro over 28. |
| referencia_externa | text | Your identifier. Makes creation idempotent. |
Fields that do not match the chosen unit are rejected, not ignored: sending dia_de_semana on a monthly plan returns a 400, so you are not left believing you configured something.
curl -X POST https://api.toki.lat/v1/servicios \
-H "Authorization: Bearer $TOKI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"nombre": "Monthly plan",
"precio": 19990,
"cada": 1,
"unidad": "month",
"dia_de_cobro": 1,
"referencia_externa": "monthly-plan"
}'{
"servicio": {
"id": "b97c3820-b9ea-4352-aba4-a459aaa02015",
"nombre": "Monthly plan",
"descripcion": null,
"precio": 19990,
"moneda": "CLP",
"recurrente": true,
"cada": 1,
"unidad": "month",
"dia_de_cobro": 1,
"dia_de_semana": null,
"politica_mes_corto": null,
"activo": true,
"referencia_externa": "monthly-plan",
"creado_en": "2026-08-25T15:50:15Z"
}
}List and edit
/v1/serviciosLists the whole catalogue of the merchant — including what was published from the app, not only what the API created.
/v1/servicios/{id}| Field | Type | What it is |
|---|---|---|
| activo | boolean | With false it stops taking new subscriptions. Existing ones keep being charged. |
| precio | integer | Applies to whoever subscribes afterwards. |
| nombre | text | |
| descripcion | text |
Changing the price does not affect anyone already subscribed. Their amount was fixed when they accepted; raising it from here would be charging them something they never authorized.
Generate the subscription link
/v1/suscripciones| Field | Type | What it is |
|---|---|---|
| servicio_id | uuid, required | The service being subscribed to. |
| referencia_externa | text | Your identifier. Makes the operation idempotent. |
| expira_minutos | integer | Between 1 and 1440. Defaults to 60: subscribing takes more thought than paying a receipt. |
curl -X POST https://api.toki.lat/v1/suscripciones \
-H "Authorization: Bearer $TOKI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "servicio_id": "…", "referencia_externa": "member-118" }'The response has the same shape as a charge —same url_checkout, same states— plus the servicio_id. You check its status with GET /v1/cobros/{id} and receive the cobro.paid webhook when the person confirms.
Test mode
Create a credential in test mode from your panel and develop against it without moving a cent. The key looks different —it starts with tk_test_— so it is never mistaken for the live one, in a log or in a config file.
A test charge cannot be paid with real money, and a real charge cannot be marked paid by simulating. Both locks live in the database, not in this documentation: they do not rely on anyone being careful.
Mark a test charge as paid
/v1/cobros/{id}/simular-pagoMarks the charge paid and fires your webhook, without writing a single ledger entry. This is how you test your thank-you page and your event handling before charging anyone.
curl -X POST https://api.toki.lat/v1/cobros/{id}/simular-pago \
-H "Authorization: Bearer $TOKI_TEST_KEY"Test charges carry es_prueba: true in the response. If your integration sees them in production, you shipped the wrong credential.
Refunds
/v1/cobros/{id}/reembolsarGives back the money of a paid charge. Without monto it returns everything still outstanding; with monto it returns that part, and you can call again until it is complete.
| Field | Type | What it is |
|---|---|---|
| monto | integer | How much to return, in the smallest unit of your currency. Omit it to return everything outstanding. |
Your customer gets back 100% of what they paid, and that amount comes out of your wallet in full. The fee does not come back: the charge was already processed, so it was already earned. In practice you return slightly more than you received — the difference is that sale's fee.
Worth planning for: to refund a sale you need the full amount available, not just what you received for it. If you already withdrew the money and cannot cover it, the refund fails with an error telling you how much is missing — we prefer that to leaving you a negative balance someone has to chase later.
curl -X POST https://api.toki.lat/v1/cobros/{id}/reembolsar \
-H "Authorization: Bearer $TOKI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "monto": 5000 }'The response carries reembolsado, the total returned so far. And we notify you by webhook: cobro.partially_refunded while something is left, cobro.refunded once it is all back.
WooCommerce
If your store runs WooCommerce, you do not need to write any code: a plugin does all of this for you.
Download the plugin ↓- 1. Install it from Plugins → Add New → Upload Plugin.
- 2. Go to WooCommerce → Settings → Payments → Toki.
- 3. Paste your test credential and leave test mode on.
- 4. Copy the "notification URL" shown there into your Toki credential, along with its signing secret.
- 5. Place a full order.
POST /v1/cobros/{id}/simular-pagomarks it paid without moving money. - 6. Once it works, paste the live credential and turn test mode off.
It handles charges, full and partial refunds from the order itself, and verifies the signature of every notification. The order is marked paid by the webhook, never because the customer came back to the store — coming back does not prove payment.
On save, the plugin asks Toki which currency you charge in and warns you if it does not match your store. Toki does not convert currencies, so in that case the method is hidden at checkout instead of charging a number in the wrong currency.
Webhooks
If you set an https URL in your panel, we notify you there when the charge changes state: cobro.paid, cobro.cancelled or cobro.expired. The body carries the event and the full charge.
{
"evento": "cobro.paid",
"cobro": { "id": "9cd827ca...", "estado": "paid", "monto": 15990, ... }
}Every delivery is signed in the X-Toki-Firma header, shaped t=<epoch>,v1=<hmac>. The HMAC is SHA-256 over ${t}.${body} with your credential's webhook secret. Always verify it: without that, anyone who knows your URL can tell you they paid.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(body, header, secret) {
const p = Object.fromEntries(header.split(',').map((x) => x.split('=', 2)));
const t = Number(p.t);
// The timestamp is INSIDE what is signed: otherwise an old webhook could be
// replayed with a fresh t and the signature would still validate.
if (Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(p.v1 ?? '', 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}We treat any 2xx as delivered. If your server fails, we retry at 1, 5, 25 and 125 minutes, then give up: the last error stays visible in your panel. Webhooks can arrive more than once, so treat the charge `id` as the key and make your processing idempotent.
What you receive
From every charge, Toki takes its fee and settles the rest to you instantly. The API does not return it as a fixed number because it is not one: it depends on the type of charge, on your plan, and on any negotiated rate with you, which overrides the rest.
Worth knowing: charging via the API pays the in-person sale rate, the lowest in the catalogue — because you are bringing your own system and Toki only provides the payment method. Selling through Toki's marketplace, with its catalogue and its shipping, costs considerably more. Subscriptions have their own rates, and charge sign-up differently from renewals.
Yours, with everything applied, is in your panel: Business → Money, next to the detail of each settlement.
Errors
All errors share the same shape. The code is stable and meant to be compared in your code; the mensaje is for you to read.
{ "error": { "code": "credencial_invalida", "mensaje": "..." } }| code | HTTP | What happened |
|---|---|---|
| sin_credencial | 401 | The Authorization header is missing. |
| credencial_invalida | 401 | Key, secret, or source IP that do not match. We do not say which, on purpose. |
| sin_alcance | 403 | The credential is not allowed to do that. |
| cuerpo_invalido | 400 | A field is missing or has the wrong type. |
| no_encontrado | 404 | No such charge for this credential. |
| estado_invalido | 409 | The charge is no longer pending. |
| demasiadas_solicitudes | 429 | You went over 120 calls per minute. |
| error_interno | 500 | Our fault. Retrying is safe if you send referencia_externa. |
Before going live
- — Keep the secret where you keep your other secrets, never in code or in the front end.
- — Set the IP list. It is the lock that still works if the secret leaks.
- — Always send
referencia_externa: it is what stops a double charge when the network fails halfway. - — Verify every webhook signature, and never decide anything from a
postMessage. - — Have a plan for when the webhook does not arrive: check the charge status before fulfilling.
Sign in with Toki
Let people sign in to your site with their Toki account. They approve on their phone with their face, and you receive only the data they authorised.
This is standard OAuth 2.0 (authorization code + PKCE), not a home-made flow. Use the library you already know, the security properties are well studied, and someone who has never heard of Toki can integrate without trusting our cryptography.
Register your application
In your company panel, under Developers, you register your app and get a client_id and a client_secret. The secret is shown once: we only store its hash, so it is not that we would rather not show it again — we do not have it.
You can integrate right away with the non-sensitive permissions. For national ID, phone, companies or authorising operations we review the app first: name, logo and declared owner. An app called "Toki Payments" carrying our logo would turn the consent screen — the very place where people trust us — into a phishing tool.
The flow
You send the person to the consent screen, they come back with a code, and your server exchanges that code for a token.
/autorizarhttps://toki.lat/autorizar
?client_id=your_client_id
&redirect_uri=https://yoursite.com/oauth/callback
&response_type=code
&scope=perfil+email
&state=<your random value>
&code_challenge=<SHA-256 of the verifier, base64url>
&code_challenge_method=S256The person sees your name, your site's real domain, who is responsible for it, and every piece of data you are asking for in plain language. If they approve, they return to your redirect_uri with code and your state untouched.
/api/oauth/tokengrant_type=authorization_code
code=<the code>
redirect_uri=https://yoursite.com/oauth/callback
client_id=your_client_id
client_secret=your_secret
code_verifier=<the original verifier>Returns access_token, refresh_token, expires_in and the scope actually granted — which may be narrower than what you asked for. Read it: it is the only way to know what data you really have.
/api/oauth/userinfoWith Authorization: Bearer <access_token>. Each field is returned only if its permission is in the token. sub is always there: it is the person's stable identifier.
/api/oauth/revokeTo sign out on your side. It always answers 200, even for a token that never existed: telling them apart would turn this route into a way to guess valid tokens.
What you can ask for
Ask for the minimum. Every extra permission is one more question the person has to answer before approving, and one more reason not to.
| Permission | What it returns | Review |
|---|---|---|
| `perfil` | Name, photo, username and whether Toki verified their identity | No |
| `email` | Email address | No |
| `rut` | Identity document and its country | Yes |
| `telefono` | Phone number | Yes |
| `empresa` | Companies they belong to and their role | Yes |
| `transacciones:autorizar` | Authorise operations on their behalf | Yes |
What you need to know
- — PKCE is mandatory and only with
S256.plainis rejected. - — The
redirect_uriis matched exactly against the ones you registered. No wildcards: allowing them would let the code land somewhere you do not control. - — The code lasts one minute and is single use. If it is exchanged twice we assume it leaked and revoke everything issued for that person in your app.
- —
refresh_tokens rotate: every exchange returns a new one and invalidates the previous. Reusing an old one is treated as theft and ends the session. - — The
client_secretnever reaches the browser. If your app cannot keep it, the exchange still works with PKCE. - — People can withdraw access at any time from their account, and live tokens are cut along with it. Your integration has to survive that without breaking.