Skip to content

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.

Base URLhttps://api.toki.lat/v1
Create a credential

Start here

How it works

  • 1. Your system calls POST /v1/cobros with 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.

Shell
Authorization: Bearer tk_9f2c8ab1de4057.9d31...c0

In 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.

ScopeWhat it unlocks
cobrosAsking for money, and undoing it: POST /v1/cobros, GET /v1/cobros/{id}, /anular, /reembolsar, /simular-pago, all of /v1/servicios and POST /v1/suscripciones.
pedidosThe micro app checkout: POST /v1/pedidos, GET /v1/pedidos/{id}, /consumir and /v1/apps/usos.
equipoYour 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.

GET/v1/comercio

Tells 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.

JSON
{ "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

POST/v1/cobros
FieldTypeWhat it is
montointeger, requiredIn 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.
conceptotextWhat your customer sees. "Receipt 4471", "Table 12".
referencia_externatextYour identifier. It also makes the charge idempotent: retrying with the same reference returns the existing charge instead of creating another.
url_retornohttpsWhere 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_cancelacionhttpsWhere they go if they back out.
expira_minutosintegerBetween 1 and 1440. Defaults to 15.
metadataobjectAnything 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" }
  }'
JSON
{
  "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.

HTML
<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

GET/v1/cobros/{id}/qr

Returns 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.

HTML
<img src="https://api.toki.lat/v1/cobros/{id}/qr" alt="Pay with Toki" />

Check a charge

GET/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

POST/v1/cobros/{id}/anular

Only 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.

JSON
{
  "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.

JavaScript
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

POST/v1/cobros/{id}/reembolsar

Gives 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.

FieldTypeWhat it is
montointegerHow 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

POST/v1/servicios
FieldTypeWhat it is
nombretext, requiredWhat your customer sees. "Monthly plan", "Member fee".
preciointeger, requiredIn the smallest unit of your currency, same as monto.
descripciontextWhat it includes.
recurrentebooleanDefaults to true. With false it stays published but takes no subscriptions: to charge it once use /v1/cobros.
cadaintegerDefaults to 1.
unidadtextday, week, month, semester or year. Defaults to month.
dia_de_cobrointeger 1–31The 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_semanainteger 0–6Only with unit week. 0 is Sunday.
politica_mes_cortotextWhat to do when the day does not exist in a month: last, first_next or skip. Only with dia_de_cobro over 28.
referencia_externatextYour 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"
  }'
JSON
{
  "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

GET/v1/servicios

Lists the whole catalogue of the merchant — including what was published from the app, not only what the API created.

PATCH/v1/servicios/{id}
FieldTypeWhat it is
activobooleanWith false it stops taking new subscriptions. Existing ones keep being charged.
preciointegerApplies to whoever subscribes afterwards.
nombretext
descripciontext

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

POST/v1/suscripciones
FieldTypeWhat it is
servicio_iduuid, requiredThe service being subscribed to.
referencia_externatextYour identifier. Makes the operation idempotent.
expira_minutosintegerBetween 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.

GET/autorizar
Shell
https://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=S256

The 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.

POST/api/oauth/token
Shell
grant_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.

GET/api/oauth/userinfo

With 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.

POST/api/oauth/revoke

To 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.

PermissionWhat it returnsReview
perfilName, photo, username and whether Toki verified their identityNo
emailEmail addressNo
rutNational ID number and its country. Works for any country: the key is called rut for historical reasons onlyYes
telefonoPhone numberYes
empresaCompanies they belong to, with their tax ID (RUT in Chile), and their roleYes
identidad:verificacionHow verified their identity is (chip, liveness, device), signed by TokiYes
identidad:documentoA copy of their document chip (data and photo) signed by the issuer, to verify it yourselfYes
transacciones:autorizarAuthorise operations on their behalfYes

What you need to know

  • — Use PKCE, and only with S256: plain is rejected. Without PKCE the exchange is only accepted with the client_secret.
  • — The redirect_uri is 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_secret never reaches the browser. If your app cannot keep it, the code exchange still works with PKCE, but refreshing requires the secret: grant_type=refresh_token without it returns invalid_client. It can go in the body or in Authorization: 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.

CapabilityWhat it enables
identidadA derived id for the person and, with confirmation, name and photo
compartirOpen the system share sheet with your text
ubicacionA one-shot reading of where they are
pagarOpen payment for a charge your backend created
comprarStart a purchase with delivery: you declare what you sell and Toki provides the checkout
nfcRead a plain NFC tag (NDEF). It cannot reach an ID document chip
documentoRestricted. Read the ID chip and receive only its images: portrait and signature
abrir_appJump 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.

MethodCapabilityReturns
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
JavaScript
// 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.

POST/v1/pedidos

You 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 happensWhat you must send
despachoThe person picks their address and we quote shipping with the courierorigen_comuna and, if you can, bulto
retiroThe person picks one of your warehouses with pickup enablednothing: the warehouses come from your profile
digitalThere’s nothing to delivernothing

⚠️ 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…).

GET/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’s expirado and you need a new one.
  • — referencia_externa is 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 returns 409 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.

POST/v1/pedidos/{id}/consumir

It 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.

Shell
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.

POST/v1/apps/usos/consumir
Shell
curl 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://…" } }'
GET/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: or reembolso:, 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.

JavaScript
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_firmada so you can see which mode you are in. If token comes back null, 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):

FieldWhat it is
portadaLandscape 16:9 image, at least 1280 px wide. It is the big image of the listing and the preview when the link is shared.
capturasUp to 8 portrait screenshots (9:19.5 or 9:16), at least 1080 px wide, in the order you choose.
descripcion_largaThe listing text, up to 4000 characters. descripcion is still the short catalog line.
novedadesWhat changed in this version, up to 1000 characters.
version_publicaThe version people see: 1, 1.4 or 1.4.2. The numeric version is bumped by Toki on every edit.
sitio_soporteHelp https URL.
correo_soporteContact email.
politica_privacidadhttps 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.

ImageAcceptedStored as
Cover16:9 (±2%), ≥ 1280 px wide1920×1080, or 1280×720 if the original is smaller
Screenshot9:19.5 or 9:16 (±3%), ≥ 1080 px wide1080×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 identidad and ubicacion) 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.
GET/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 iframe from 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.pagar only 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.pagar verifies the charge belongs to the business profile that owns the app. Another merchant’s charge answers params.
  • — 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-pago marks 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

POST/v1/cobros/{id}/simular-pago

Marks 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.

JSON
{ "error": { "code": "credencial_invalida", "mensaje": "..." } }
codeHTTPWhat happened
sin_credencial401The Authorization header is missing.
credencial_invalida401Key, secret, or source IP that do not match. We do not say which, on purpose.
sin_alcance403Your credential is missing the scope that route requires. The message names the missing one and lists yours — see "Scopes".
cuerpo_invalido400A field is missing or has the wrong type.
no_encontrado404That id (charge, order, service…) does not exist or is not this credential’s. Both get the same answer on purpose.
estado_invalido409It 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_usos409That person has no uses of that item left. Carries disponibles — see “Uses per buyer”.
referencia_debil400The referencia of a consumption is a constant, shorter than 8 characters, or uses a reserved prefix.
identidad_requerida, identidad_invalida, identidad_vencida, identidad_no_coincide401The x-toki-identidad token is missing or not valid — see “Signed identity”.
demasiadas_solicitudes429You hit a limit: 120 calls per minute per credential, 600 per IP, or 30 messages per minute in a channel.
error_interno500Our 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.