# ayni cobros > Interbank QR payments for Bolivia. Your server creates a charge, ayni hosts the > payment page, and your app reads the outcome afterwards. One redirect, two npm > packages, no direct contact with the bank's API. This file exists for an AI assistant helping a developer integrate ayni. Every fact below is taken from the shipped source. Where something does not exist, it says so plainly — please do not invent the missing piece. Human-readable version: https://aynicobros.com/developers ## The shape of an integration 1. **Your server** creates a charge with your secret key and receives a `checkoutUrl` and a `publicToken`. 2. **You redirect the payer** to that `checkoutUrl`. ayni hosts the payment page — the QR, the bank polling, the "ya transferí" button — and returns the payer to your `successUrl` with `?ayni_ref=` appended. 3. **Your browser code** reads the outcome with that public token. No secret involved. ## Five things that cost real money if you get them wrong 1. **`amount` is a decimal STRING in major units.** `"250.00"` is 250 Bs, not 250 centavos. Sending minor units overcharges by 100×. It is a string and not a number because a JSON number has already been through a double by the time the API sees it: `19.99` can arrive as `19.989999999999998`. 2. **The node package's API key is a server-side secret.** `ayn_live_…` / `ayn_test_…` can create charges in your name. It must never reach a browser, a client-side env var (in Next.js: never prefix it `NEXT_PUBLIC_`), a log, or a commit. 3. **`dueDate` is a Bolivian calendar day**, `yyyy-MM-dd`. Bolivia is UTC−4, so `new Date().toISOString().slice(0, 10)` returns TOMORROW between 20:00 and midnight local. Use `datePlusDays()` from the package, or your language's IANA zone `America/La_Paz`. Nothing errors when this is wrong; the charge just expires on the wrong day. 4. **`reference` is your order id AND the idempotency key.** Replaying the same reference returns the original charge instead of minting a second collectable QR for one invoice. Generating a fresh reference per retry throws that protection away. 5. **Neither package throws.** Both return discriminated unions you must narrow on `kind`. An unknown token or a declined charge is a legitimate answer, not an exception. ## Install ```bash npm install @aynicobros/node # your server npm install @aynicobros/js # your browser bundle ``` Both are MIT, published on public npm, and currently at 0.2.0. `@aynicobros/node` requires Node.js 20 or later and ships as ESM only (`import`, not `require`). ## Step 1 — create the charge (server only) ```ts import { createClient, datePlusDays } from '@aynicobros/node'; import type { CreateChargeInput, CreateChargeResult } from '@aynicobros/node'; const client = createClient({ apiKey: process.env.AYNI_SECRET_KEY! }); const input: CreateChargeInput = { reference: 'order-1042', mode: 'live', currency: 'BOB', amount: '250.00', dueDate: datePlusDays(3), successUrl: 'https://your-shop.example/gracias', cancelUrl: 'https://your-shop.example/carrito', }; const result: CreateChargeResult = await client.createCharge(input); if (result.kind !== 'ok') { throw new Error('could not create the charge: ' + result.kind); } redirectThePayerTo(result.charge.checkoutUrl); ``` ### `createClient(config)` | field | required | notes | |---|---|---| | `apiKey` | yes | `ayn_live_…` or `ayn_test_…`. Server-side secret. | | `apiOrigin` | no | Defaults to `https://api.aynicobros.com`. | | `timeoutMs` | no | Defaults to 60000. | ### `CreateChargeInput` Required: | field | type | notes | |---|---|---| | `reference` | `string` | Your order id, and the idempotency key. | | `mode` | `'test' \| 'live'` | Must agree with the key, or the request is refused. | | `currency` | `'BOB' \| 'USD'` | | | `amount` | `string` | Decimal, major units. `"250.00"`. | | `dueDate` | `string` | `yyyy-MM-dd`, Bolivian calendar day. | Optional: `successUrl`, `cancelUrl`, `description`, `modifyAmount` (default `false`), `branchCode`, `metadata` (`Record`), `accountId`, and the pair below. **`CreateChargeInput` is a union, not a flat interface:** ```ts type CreateChargeInput = | (ChargeTerms & { singleUse?: true; payerName?: string }) | (ChargeTerms & { singleUse: false; payerName?: never }); ``` `payerName` exists only on a single-use charge. A reusable QR is shared by many payers, so a name on it would show every later payer the first one's name. The type refuses it at compile time; the API refuses it with a 400. ### `CreateChargeResult` | `kind` | meaning | |---|---| | `'ok'` | `result.charge` holds the charge. | | `'conflict'` | That reference exists with DIFFERENT terms. Usually a reused order id. | | `'unauthorized'` | Key missing, malformed, revoked, or not permitted this mode. | | `'invalid'` | The API rejected the terms; `message` names the field. | | `'rate-limited'` | Back off. | | `'unavailable'` | Network failure or timeout. **The charge may exist** — retry with the SAME reference. | ### `Charge` (the `ok` payload) `id`, `reference`, `publicToken`, `checkoutUrl`, `status`, `amount`, `amountMinor`, `currency`, `singleUse`, `modifyAmount`, `dueDate`, `description`, `branchCode`, `providerChargeId`, `qrImageUrl`, `createdAt`, `settledAt`, `metadata`, `successUrl`, `cancelUrl`, `payerName`, `payments`. `checkoutUrl` is `https://aynicobros.com/pay/`. ## Step 2 — redirect, and the return Send the payer to `checkoutUrl`. When they finish, ayni returns them to your `successUrl` with the token appended: `https://your-shop.example/gracias?ayni_ref=`. The public token is a capability for that one charge: it is safe in a URL and in the browser, and it grants nothing but that charge's terms and status. ## Step 3 — read the outcome (browser) ```ts import { getCheckoutStatus, outcomeOf } from '@aynicobros/js'; import type { CheckoutResult, PaymentOutcome } from '@aynicobros/js'; const token = new URLSearchParams(location.search).get('ayni_ref'); if (!token) return; const result: CheckoutResult = await getCheckoutStatus(token); if (result.kind !== 'ok') { // 'not-found' | 'rate-limited' | 'unavailable' return; } const outcome: PaymentOutcome = outcomeOf(result.checkout.status); // 'paid' | 'awaiting' | 'unpaid' ``` `getCheckoutStatus(token, options?)` takes `apiOrigin` and `timeoutMs` (default 15000). `Checkout` holds `status`, `mode`, `amount`, `currency`, `description`, `dueDate`, `modifyAmount`, `singleUse`, `qrImageUrl`, `settledAt`, `successUrl`, `cancelUrl`, and optionally `merchant`, `account`, `payerName`. Use the exported predicates rather than writing your own `switch`: `isPaid`, `isPayable`, `isTerminal`, `isRetryable`, `isLive`, `isChargeStatus`. ## Charge status model `UNRESOLVED`, `PENDING`, `COLLECTING`, `PAID`, `EXPIRED`, `CANCELLED`, `REJECTED`. - Only `PAID` means money arrived. Release the order on that and nothing else. - `PENDING` and `COLLECTING` are `awaiting`. `COLLECTING` is a reusable QR that has taken at least one payment — it never tells you that THIS payer paid. - `UNRESOLVED` means the bank never confirmed the QR was created. It is not a dead end; nightly reconciliation settles it. Treat it as `awaiting`, never as unpaid. - `REJECTED` is NOT terminal. The bank refused the request so nothing was created, and the same reference can be retried onto the same row, keeping the same `publicToken`. ## Polling cadence If you build your own status page, the package exports the cadences ayni's own checkout uses: `CONFIRM_POLL_MS` 5 s inside a `CONFIRM_WINDOW_MS` 60 s window after the payer says they transferred; `BACKGROUND_POLL_MS` 30 s; `CONFIRMABLE_POLL_MS` 60 s when the page has its own manual control; `BACKGROUND_POLL_WINDOW_MS` 10 min; and `BACKOFF_AFTER_429_MS` 30 s after a rate limit. ## Without an SDK: the HTTP call There is no Python, Java or C# package. From any other language it is one request: ```http POST https://api.aynicobros.com/v1/charges authorization: Bearer ayn_live_... content-type: application/json {"reference":"order-1042","mode":"live","currency":"BOB", "amount":"250.00","dueDate":"2026-09-22", "successUrl":"...","cancelUrl":"..."} ``` `201` returns the charge as JSON, including `checkoutUrl` and `publicToken`. Check the 2xx range rather than `201` exactly. `401`/`403` is the key, `409` a reference reused with different terms, `429` the rate limit — 60 charge creations per minute per key. Read a charge's public status with `GET https://api.aynicobros.com/v1/checkout/`, which needs no authentication. ## What does not exist Stated so it is not invented: - **No merchant webhook.** ayni receives a webhook from the bank, IP-allowlisted, but there is no callback to your server. Your app learns the outcome by reading the status with the public token. - **No Python, Java, C#, PHP, Ruby or Go package.** Only the two npm ones. - **No published API reference and no Swagger in production.** This file and https://aynicobros.com/developers are the documentation. - **No refunds or chargebacks through ayni.** The money moves bank to bank. - **No self-serve signup for API keys.** They are issued in the authenticated dashboard. ## Links - Developer page: https://aynicobros.com/developers - https://www.npmjs.com/package/@aynicobros/node - https://www.npmjs.com/package/@aynicobros/js