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/v1Start here
How 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, and 600 per minute from a single IP (counted before the key is checked, to slow down anyone guessing secrets). Going over returns 429 with reintentar_en_s.
Scopes
Every credential carries a list of scopes, and every route requires one. They exist so you can hand a key to an outside integrator without handing over your till: a key made to build orders should not be able to refund a charge.
| Scope | What it unlocks |
|---|---|
| cobros | Asking for money, and undoing it: POST /v1/cobros, GET /v1/cobros/{id}, /anular, /reembolsar, /simular-pago, all of /v1/servicios and POST /v1/suscripciones. |
| pedidos | The micro app checkout: POST /v1/pedidos, GET /v1/pedidos/{id}, /consumir and /v1/apps/usos. |
| equipo | Your brand's chat: GET /v1/canales, POST /v1/canales/{id}/mensajes and /v1/invocaciones/{id} (read and reply). It never touches money. |
Two routes sit outside the table on purpose: GET /v1/comercio requires no scope —it is "who am I", and it says nothing the credential does not already stand for— and GET /v1/cobros/{id}/qr takes no credential at all, because it gets printed on a receipt.
Three answers not to mix up. 403 sin_alcance means "your key does not have that permission" — the message names the missing scope and lists the ones you have, so you fix it by issuing another credential, not by changing the id. 404 no_encontrado means "that id does not exist, or it is not this credential's": both answer the same way on purpose, because telling them apart would tell anyone which ids exist in Toki. 409 estado_invalido means "it exists and it is yours, but it is no longer pending".
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.
Charging
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. With a test credential http is accepted too (here and in url_cancelacion), so you can integrate from localhost. |
| 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, except the reserved keys origen, items, dte and dte_error, which are dropped. |
curl -X POST https://api.toki.lat/v1/cobros \
-H "Authorization: Bearer $TOKI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"monto": 15990,
"concepto": "Boleta 4471",
"referencia_externa": "b-4471",
"url_retorno": "https://mitienda.cl/gracias",
"url_cancelacion": "https://mitienda.cl/carro",
"metadata": { "caja": "3" }
}'{
"cobro": {
"id": "9cd827ca-d10d-4768-8627-265d875f1cd2",
"monto": 15990,
"moneda": "CLP",
"concepto": "Receipt 4471",
"estado": "pending",
"reembolsado": 0,
"es_prueba": false,
"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://toki.lat/api/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.
After the charge
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 (also in the X-Toki-Evento header) and the full charge.
{
"id": "5b1e0c4a...",
"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 after 5, 25, 125 and 625 minutes (five attempts in all, about ten hours), then give up: the last error stays visible in your panel. Webhooks can arrive more than once, so use the notice `id` (also in the `X-Toki-Evento-Id` header) to drop duplicates: it stays the same across retries. And make your processing idempotent on the charge id.
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.
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.
Digital goods charged by the store split differently (19-Sep-2026). When the charge goes through App Store or Google Play, Toki keeps 41.9 % + VAT of the published price and pays the store commission out of that: from $10,000 the seller gets $5,014, whether Apple or Google charged it. Outside the stores — web, card or wallet — Toki keeps 10 % + VAT: from $10,000 you get $8,810. Your net does not depend on the store; Toki absorbs the difference between Apple and Google.
Subscriptions
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.
Identity
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 the identity document, 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 (valid for one hour), refresh_token (30 days), 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 | National ID number and its country. Works for any country: the key is called rut for historical reasons only | Yes |
telefono | Phone number | Yes |
empresa | Companies they belong to, with their tax ID (RUT in Chile), and their role | Yes |
identidad:verificacion | How verified their identity is (chip, liveness, device), signed by Toki | Yes |
identidad:documento | A copy of their document chip (data and photo) signed by the issuer, to verify it yourself | Yes |
transacciones:autorizar | Authorise operations on their behalf | Yes |
What you need to know
- — Use PKCE, and only with
S256:plainis rejected. Without PKCE the exchange is only accepted with theclient_secret. - — 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 code exchange still works with PKCE, but refreshing requires the secret:grant_type=refresh_tokenwithout it returnsinvalid_client. It can go in the body or inAuthorization: Basic. - — 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.
Mini apps
What they are
A mini app is a site of yours that opens full screen inside Toki, with access to a bridge that lets it ask the app for things: who the person is, their location, opening a payment. There is no SDK for you to install —people do install your app from the Toki store, see *Your listing in the store*—: Toki injects window.toki into your page before it loads.
The absence of an SDK is deliberate. If you had to install a package, every change to the bridge would force you to update; injected, we move the contract without breaking you.
Your app runs on its own origin and only talks to Toki through messages. Nothing is injected into it: no token, no identifier for the person, none of their data. Anything it wants to know it has to ask for, and every request passes two filters — what you declared in the manifest, and what the person allowed.
The manifest
Every app registers with a manifest. What matters: the clave (lowercase and hyphens, your stable identifier), the start url — https required — and the capacidades you are going to ask for.
The manifest capabilities are the complete list of what your app can ever do, and the person sees it before entering. A capability you did not declare cannot be requested: the bridge answers sin_capacidad without asking anyone. Declare the minimum — every extra permission is a reason not to open your app.
| Capability | What it enables |
|---|---|
| identidad | A derived id for the person and, with confirmation, name and photo |
| compartir | Open the system share sheet with your text |
| ubicacion | A one-shot reading of where they are |
| pagar | Open payment for a charge your backend created |
| comprar | Start a purchase with delivery: you declare what you sell and Toki provides the checkout |
| nfc | Read a plain NFC tag (NDEF). It cannot reach an ID document chip |
| documento | Restricted. Read the ID chip and receive only its images: portrait and signature |
| abrir_app | Jump to another mini app, with confirmation |
⚠️ `documento` is a restricted capability. Declaring it isn’t enough: a Toki administrator grants it to your app specifically, with a recorded reason, and until then the bridge answers sin_capacidad and the app can’t be published. It hands over only the images —portrait and signature—: never the name, ID number or dates. And Toki does the reading: your page never talks to the chip. The nfc capability, which is self-service, reads plain NDEF tags and cannot reach a document — not a block on our side: a document chip publishes no NDEF.
An app goes borrador → en_revision → publicada. Only publicada is visible outside your team, and it can go back to rechazada or suspendida.
You register the app yourself, from your panel under My company → Micro apps. That’s where you write the manifest, save it as a draft as many times as you need, and send it for review when it’s ready. We review; we don’t write it for you.
Every capability you check needs one sentence from you saying why you need it (200 characters). It doesn’t replace our explanation of the permission — it completes it. “Location” can’t be answered with any information; “to quote shipping to your address” can. Without that sentence the app can’t go to review: a capability with no reason can only be rejected.
That text is shown to the person at the moment your app uses the permission, not when they open it: that’s when they can decide with context. And it’s attributed to you, not to Toki.
The toki.* bridge
Each method is a function that takes an object and returns a promise. The object is frozen: it cannot be replaced or extended.
| Method | Capability | Returns |
|---|---|---|
| toki.info() | — | { v, idioma, tema, plataforma, version, capsula } — capsula: the space, in CSS px, your page keeps clear at the top right |
| toki.identidad() | identidad | { id, token, expira_en, vigencia_s } — derived, plus a signed token; see below |
| toki.perfil() | identidad | { nombre, foto }, confirmed every time |
| toki.compartir({ texto, url }) | compartir | { ok: true }. You will not know if they shared |
| toki.ubicacion() | ubicacion | { lat, lng, precision } |
| toki.pagar({ cobroId }) | pagar | { abierto: true } |
| toki.pedido({ pedidoId }) | comprar | { abierto: true } — opens the purchase sheet of one of your orders |
| toki.abrirApp({ clave }) | abrir_app | { abierto: true } |
| toki.nfc() | nfc | { id, texto, registros } — a plain NDEF tag |
| toki.documento() | documento | { foto, firma } — images in base64; restricted |
| toki.cerrar() | — | closes and returns to Toki |
// window.toki already exists at load time; there is nothing to wait for.
const { id } = await toki.identidad();
try {
await toki.pagar({ cobroId });
} catch (e) {
if (e.codigo === 'cancelado') return; // the person said no
throw e;
}Errors arrive as an Error with a stable codigo: sin_capacidad (you did not declare it), sin_permiso (the person said no), cancelado, params, metodo_desconocido, mal_formado, interno.
Who the person is
toki.identidad() does not return the person’s real identifier in Toki, but one derived from the pair (person, app). It is stable for you and different in every app: two mini apps cannot cross their databases and discover they are talking to the same person.
Name and photo are a different matter: they go through toki.perfil() and ask for confirmation every time, because from inside the app we cannot tell whether the request came from a button the person pressed or from a loop. A "no" holds for the whole session; insisting does not open another dialog.
What your app will never be able to read: chats, contacts, orders, balance, payment methods, the person’s session or their address. It also cannot run in the background or send notifications.
Selling with delivery
A charge is for charging an amount. An order (pedido) is for selling something that has to be delivered: your app declares the lines and Toki provides the checkout — the person’s address book, the courier quote, the total and the confirmation. It needs the comprar capability.
It’s a different entity from Merqado orders, on purpose: what your micro app sells is yours and isn’t in our catalogue. That’s why you create the order through the API instead of picking our products.
/v1/pedidosYou send lines, never a total. We compute the total as subtotal minus discount plus delivery; if the total travelled in the body, the buyer could confirm their order with zero shipping.
⚠️ Since 19-Sep-2026 every line carries the clave of an item in your catalog (My company → Micro apps → Items), and its precio_unitario must match the catalog price. A line without clave is rejected with 400 cuerpo_invalido; with a clave that isn’t in your active catalog the message starts with linea_fuera_de_catalogo, and with a different price, with precio_no_coincide (both are 400 cuerpo_invalido too). If your price varies by size or quantity, declare one item per variant: changing the catalog needs no release.
The catalog price is not always on the item. If the item declares its own amount, that one wins and nothing else is looked at. If it leaves it empty and hangs from a service in your Mercado catalog, the catalog price is that service’s — and only while the service is active: an archived one does not lend its price. If there is neither, the item has no price and the line returns precio_no_coincide whatever number you send; it is not “price to be agreed”, it is an item that cannot be sold.
The practical consequence: an item that inherits changes price when somebody edits the service, without touching the micro app or republishing it. If your backend keeps the price in a copy of its own, it will start sending the old one and getting precio_no_coincide without having changed anything. Read the current price from the panel (My company → Micro apps → Items, which shows the price and where it comes from), or declare the amount on the item so it does not depend on the service.
curl https://api.toki.lat/v1/pedidos \
-H "Authorization: Bearer $TOKI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"app": "foto-trama",
"items": [{ "nombre": "10 prints 10x15", "cantidad": 1, "precio_unitario": 6990, "clave": "copias-10x15" }],
"entrega": { "tipo": "despacho", "origen_comuna": "PROV", "bulto": { "peso_g": 400 } },
"referencia_externa": "order-1042"
}'The three ways to deliver
| `entrega.tipo` | What happens | What you must send |
|---|---|---|
| despacho | The person picks their address and we quote shipping with the courier | origen_comuna and, if you can, bulto |
| retiro | The person picks one of your warehouses with pickup enabled | nothing: the warehouses come from your profile |
| digital | There’s nothing to deliver | nothing |
⚠️ origen_comuna is the Chilexpress county code (PROV, STGO), not the name. With the name the quote fails only at confirmation time, far from this call.
For retiro you need at least one warehouse with pickup enabled under My company → Warehouses. That’s also where the pickup cost comes from: it isn’t sent in the body, same as shipping.
If you accept more than one, don’t ask yourself. Send entrega.tipos: ["despacho", "retiro"] and the Toki sheet asks the person how they want it, showing the estimated shipping and the pickup cost of each option. entrega.tipo becomes the one proposed first. Asking on your page is hand-rolled checkout: you’d need to know warehouses and costs, and create a different order per answer. digital can’t be combined with the others —that would let the payer pick the payment rail—, and if you offer despacho, origen_comuna is still required.
And then, on the phone
With the id we returned, your page opens Toki’s checkout: await toki.pedido({ pedidoId: id }). There the person picks an address or a warehouse, sees the quoted total and pays. The amount doesn’t travel in that call — the order already has a price. If the order expired, is no longer pending or belongs to another app, it answers params with a detalle saying so (pedido_vencido, pedido_pagado, pedido_de_otra_app…).
/v1/pedidos/{id}This is your source of truth, not whatever the bridge returns: that last one is your own code telling you how it went, on a device you don’t control. Here you get the estado, the quoted despacho, the total and —for pickup— entrega.retiro with the name, address and county of the place the person is going to, so you can tell them.
What you will never receive is the buyer’s address. They pick it in our sheet and it’s used to ship; if Toki collects at your warehouse and ships, you don’t need it. That’s the whole point.
The notification arrives as pedido.paid with the order id —not the charge id, which you never saw. There are also pedido.cancelled, pedido.expired, pedido.refunded and pedido.partially_refunded. They’re signed like the charge ones.
- — An order lives 30 minutes by default (
expira_minutos). After that it’sexpiradoand you need a new one. - —
referencia_externais your idempotency: the same reference with the same credential returns the order you already created, not a new one. If that order already expired, send a new reference. - — Each line’s
clave(mandatory, see above) is also what deducts stock and ties the order to the declared item. Not enough stock returns409 estado_invalido. - — An order belongs to one seller. A cart with two micro apps is a different problem and doesn’t exist yet.
Redeeming a paid order
If what you sell costs money to produce —a model-generated image, a report, a minute of video— you need to know that order wasn’t redeemed already. Keeping that count on your side is harder than it looks: in memory it doesn’t work (two instances can’t see each other) and in your database it’s a counter you must keep consistent with what someone else charged. Since Toki charged, Toki keeps the count.
/v1/pedidos/{id}/consumirIt answers ok only if the order is paid and had a use left, and spends it in the same call. The referencia in the body is yours and makes the redemption idempotent: if the response is lost after we counted the use, retrying with the same reference returns the same thing —with repetido: true— instead of spending another. Without that, a timeout charges twice someone who bought once.
curl https://api.toki.lat/v1/pedidos/$ORDER/consumir \
-H "Authorization: Bearer $TOKI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "referencia": "generation-1" }'- — 404 — the order doesn’t exist, or isn’t yours.
- — 409 — not paid yet (the message says which state), or it already used all its redemptions.
- — An order grants one redemption by default.
Uses per buyer
The above counts redemptions of ONE order. If you sell packs —1 photo for $790, 10 for $4,990— what the person needs is something else: uses of THEIR OWN. They buy two packs of 10, they have 20, and they spend them whenever without remembering which order paid for which. That doesn’t travel over the API: your catalogue declares it with usos_por_compra on the item, and Toki credits them by itself when the order is paid. An item without usos_por_compra —a printed frame— leaves no uses.
/v1/apps/usos/consumircurl https://api.toki.lat/v1/apps/usos/consumir \
-H "Authorization: Bearer $TOKI_API_KEY" \
-H "Content-Type: application/json" \
-H "x-toki-identidad: $TOKI_IDENTIDAD" \
-d '{ "comprador": "'$COMPRADOR'", "item": "fotos-10", "referencia": "photo-9f3ac1-v1", "entregable": { "url": "https://…" } }'/v1/apps/usos?comprador={comprador}&item={item}comprador is the pseudonym you already know: the same one you get from the identidad capability and in pedido.comprador. You will never receive who the person is. The GET is for painting “7 left” before they hit the button, but it is NOT a control: between reading and delivering they can spend from another phone. The one that decides is the POST, which deducts and answers in the same call. It deducts unidades (1 to 1000, default 1), and with a test credential it spends nothing: it answers with simulado: true.
⚠️ The referencia must identify WHAT you deliver, never be a constant. Repeating it answers ok with repetido: true every time you call — that is what saves you from a timeout, and what makes it useless as a limit if it is always the same. That’s why we store your entregable and give back the stored one on a retry: so you don’t pay your provider again to generate the same thing. Obvious constants (“descarga”, “uso”, “default”) are rejected.
- — 404 — that person has no uses of that item, or the item isn’t yours.
- — 409 `sin_usos` — not enough left. The response carries
disponibles. - — 400 `referencia_debil` — your reference is an obvious constant, shorter than 8 characters, or starts with
pedido:orreembolso:, which Toki uses. - — Refunding the order takes its uses back. If the person already spent any, the refund does not go through on its own: the two of you settle it.
Signed identity
Your own page hands you the comprador, so on its own it proves nothing: anyone who knows someone else’s pseudonym would burn their uses. That’s why toki.identidad() returns, alongside the pseudonym, a token signed by Toki. You forward it as-is in the x-toki-identidad header and we check that the pseudonym belongs to whoever it claims. The pseudonym is still the key to the balance: the token proves whose it is, it does not replace it.
const { id, token, expira_en } = await toki.identidad();
// id → the pseudonym (64 hex). It is the key to their balance.
// token → a 15-min credential. Send it to YOUR backend, never in a URL.
await fetch('/my-backend/job', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ persona: id, identidad: token }),
});⚠️ The token is a credential, not a piece of data. Don’t write it to logs, don’t put it in a URL or a query parameter —it ends up in history, in the Referer and in the logs of every server it passes through— and don’t keep it past its expiry.
- — It is bound to your app. It is signed with a key that exists only for your app: a token from another mini app cannot validate here, nor yours there.
- — It does not carry the person’s uid or anything that would let you cross them with another app. Only the pseudonym you already knew.
- — It lives 15 minutes. Calling
toki.identidad()again gives you another one without asking the person anything. Get one before each long job. - — 401 `identidad_vencida` — it expired mid-job. Renew and retry with the same `referencia`: it is idempotent, so it does not spend an extra use nor make you regenerate anything.
- — 401 `identidad_requerida` — your app already closed the old mode and you sent no token. 401 `identidad_no_coincide` — that token belongs to someone other than the
comprador. 401 `identidad_invalida` — everything else. - — Until your app requires it, it is optional —but if you send it, it is verified all the same—. The response carries
consumo.identidad_firmadaso you can see which mode you are in. Iftokencomes backnull, that person is on a Toki version older than this.
Your listing in the store
People discover your app on a listing and install it before opening it. You build the listing from My business → Mini apps, with these fields on top of the manifest (none is required to publish, but a listing without a cover or screenshots is not very convincing):
| Field | What it is |
|---|---|
| portada | Landscape 16:9 image, at least 1280 px wide. It is the big image of the listing and the preview when the link is shared. |
| capturas | Up to 8 portrait screenshots (9:19.5 or 9:16), at least 1080 px wide, in the order you choose. |
| descripcion_larga | The listing text, up to 4000 characters. descripcion is still the short catalog line. |
| novedades | What changed in this version, up to 1000 characters. |
| version_publica | The version people see: 1, 1.4 or 1.4.2. The numeric version is bumped by Toki on every edit. |
| sitio_soporte | Help https URL. |
| correo_soporte | Contact email. |
| politica_privacidad | https URL. Strongly recommended if you ask for anything sensitive (identity, location, NFC, ID document, payments). |
Images live in Toki, just like the icon: you upload them from the panel (or paste a URL and Toki copies it once), they are validated by their bytes —PNG, JPEG or WEBP, no SVG, up to 4 MB— and center-cropped to a fixed size. A URL on your server is never stored: if your server goes down, your listing stays whole.
| Image | Accepted | Stored as |
|---|---|---|
| Cover | 16:9 (±2%), ≥ 1280 px wide | 1920×1080, or 1280×720 if the original is smaller |
| Screenshot | 9:19.5 or 9:16 (±3%), ≥ 1080 px wide | 1080×2340 or 1080×1920 |
The listing also shows, without you writing anything else: your business and whether it is verified, which permissions your app asks for and when, what data it uses —derived from your capabilities and the sentence you wrote for each— and what you sell with its price.
- — Essentials, on install. The capabilities you mark as essential (only
identidadandubicacion) are accepted with one tap when installing, listed with your sentence. Without them your app does not open. - — Everything else, in context. The rest is asked the first time your app uses it, and payments, purchases, sharing and opening another app are confirmed every time. Not asking for permissions on launch still applies.
- — If you add an essential in a new version, people who already installed are asked before opening: it is never granted silently. Add one only if your app truly does not work without it.
/v1/apps/{clave}The same listing as JSON, without credentials: use it to show on your site how people see you in Toki. It carries nombre, portada, capturas, desarrollador (with verificado), permisos (with esencial, sensible, momento and your declaracion), datos, compras (with precio_clp), version_publica, novedades and soporte. Published apps only: anything else is 404. Cached for 5 minutes.
Non-negotiable rules
- — Don’t ask for permissions on load. Toki shows the sheet at the moment your app uses the capability, so calling
toki.identidad()at startup asks the person to decide about something they haven’t done yet — and the reasonable answer to that is “no”. Ask in the action that needs it. Reviewers look at this. - — `https` only, and only your origin. A link to another site opens in the system browser, outside Toki; an
iframefrom another domain does not load. If it did, that frame would hold your app’s permissions. - — The amount never travels over the bridge. Your backend creates the charge against the API with your credential, and
toki.pagaronly opens one that already exists with its price. A price that arrives from the page is a price that gets edited with the console open. - — You can only open your own charges.
toki.pagarverifies the charge belongs to the business profile that owns the app. Another merchant’s charge answersparams. - — Third-party cookies are blocked inside the container. Your page keeps its own; what stops working is a third party’s pixel following the person across mini apps.
- — Toki does not hand your page its cookies or its session, and your page cannot reach
file://or open windows without a gesture.
Tools
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.
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.
Reference
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 | Your credential is missing the scope that route requires. The message names the missing one and lists yours — see "Scopes". |
| cuerpo_invalido | 400 | A field is missing or has the wrong type. |
| no_encontrado | 404 | That id (charge, order, service…) does not exist or is not this credential’s. Both get the same answer on purpose. |
| estado_invalido | 409 | It exists and it is yours, but it is not in a state to do that: the charge is no longer pending, the balance does not cover the refund, the order is not paid… |
| sin_usos | 409 | That person has no uses of that item left. Carries disponibles — see “Uses per buyer”. |
| referencia_debil | 400 | The referencia of a consumption is a constant, shorter than 8 characters, or uses a reserved prefix. |
| identidad_requerida, identidad_invalida, identidad_vencida, identidad_no_coincide | 401 | The x-toki-identidad token is missing or not valid — see “Signed identity”. |
| demasiadas_solicitudes | 429 | You hit a limit: 120 calls per minute per credential, 600 per IP, or 30 messages per minute in a channel. |
| 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.