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 credential

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.

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.

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.

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

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

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.

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

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"
  }'
{
  "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.

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.

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.

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.

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": "..." } }
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_alcance403The credential is not allowed to do that.
cuerpo_invalido400A field is missing or has the wrong type.
no_encontrado404No such charge for this credential.
estado_invalido409The charge is no longer pending.
demasiadas_solicitudes429You went over 120 calls per minute.
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.

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.

GET/autorizar
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
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, 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.

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
`perfil`Name, photo, username and whether Toki verified their identityNo
`email`Email addressNo
`rut`Identity document and its countryYes
`telefono`Phone numberYes
`empresa`Companies they belong to and their roleYes
`transacciones:autorizar`Authorise operations on their behalfYes

What you need to know

  • PKCE is mandatory and only with S256. plain is rejected.
  • — 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 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.