@aynicobros/node
v0.2.0
Published
Server-side client for ayni cobros. Creates charges with your secret key and hands back the hosted-checkout link to redirect a payer to.
Maintainers
Readme
@aynicobros/node
Server-side client for ayni cobros: create a charge with your merchant secret key and get back the hosted-checkout link to redirect a payer to.
Where this fits
An ayni integration is two packages across a redirect:
@aynicobros/node(this package, on your server) creates a charge with your secret key and gets back acheckoutUrland apublicToken.- You redirect the payer to
checkoutUrl. Ayni hosts the payment page — QR, bank polling, "ya transferí" — and returns them to yoursuccessUrlwithayni_ref=<publicToken>appended. - Your browser code reads the outcome with
@aynicobros/js, which authenticates with that public token and needs no secret.
This package only does step 1. It has no DOM types, on purpose — a browser-only mistake fails to compile instead of failing in production.
Install
npm install @aynicobros/nodeRequires Node.js 20 or later. Ships as ESM only (import, not require).
Quick start
import { createClient, datePlusDays } from '@aynicobros/node';
const client = createClient({
apiKey: process.env.AYNI_SECRET_KEY!, // ayn_live_... or ayn_test_...
});
const result = await client.createCharge({
reference: 'order-1042', // YOUR order id — also the idempotency key
mode: 'live', // must agree with the key's own mode (see below)
currency: 'BOB',
amount: '250.00', // a decimal STRING — see "Amounts" below
dueDate: datePlusDays(3),
successUrl: 'https://shop.example.com/checkout/success',
cancelUrl: 'https://shop.example.com/checkout/cancel',
});
switch (result.kind) {
case 'ok':
// Send the payer here.
redirect(result.charge.checkoutUrl);
break;
case 'conflict':
// `reference` already exists with DIFFERENT terms. Not a retry — this is
// almost always a reused order id, or a replay that dropped `singleUse: false`.
log.error('charge reference conflict', result.message);
break;
case 'unauthorized':
// The key is missing, malformed, revoked, or not permitted this mode.
log.error('ayni auth failed', result.message);
break;
case 'invalid':
// The API (or this client, before the request was even sent) rejected the
// terms. `message` names the field.
log.error('invalid charge terms', result.message);
break;
case 'rate-limited':
// Back off before retrying.
break;
case 'unavailable':
// Network failure, timeout, or an unexpected status. The charge MAY have
// been created — retry with the SAME reference, never a fresh one.
log.warn('ayni unavailable, will retry by reference');
break;
}CreateChargeResult is a discriminated union, not an exception. Handle every
kind — a handler that only reads result.charge on the happy path throws
on every other branch, and on a payments path several of those branches are
routine, not exceptional.
The secret key never leaves your server
apiKey is a merchant secret (ayn_live_… or ayn_test_…). Anything
holding it can create charges in your name. It must never reach a browser
bundle, a client-side environment variable, a log line, or a git commit. Read
it from your own secret store — this package does not read process.env for
you, so where the key comes from is your application's decision, not this
library's.
This is also why Charge.payments (below) should never be forwarded
wholesale to a browser: it carries the payer's bank identity.
API
createClient(config)
| Field | Type | Required | Notes |
| ----------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| apiKey | string | yes | Merchant secret key. Throws TypeError if empty. |
| apiOrigin | string | no | Defaults to ayni's API. Pass one only to override; an explicitly empty string throws rather than silently falling back. |
| timeoutMs | number | no | Defaults to DEFAULT_TIMEOUT_MS (60 000 ms). Generous on purpose: creating a charge calls the bank, and the bank client itself retries up to three times at 15 s each plus backoff. |
Returns a client with one method, createCharge.
client.createCharge(input)
input (CreateChargeInput):
| Field | Type | Required | Notes |
| -------------------------- | ------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| reference | string | yes | Your order id, and the idempotency key. Letters, digits, and . _ : -, 1–64 characters. Replaying the same reference returns the original charge instead of minting a second QR — retry after a timeout by reusing it. |
| mode | 'test' \| 'live' | yes | Must agree with the key's own mode. The API takes the actual mode from the key, not this field — this field exists so a mismatch is a loud, immediate rejection instead of a silent charge created in the wrong universe. |
| currency | 'BOB' \| 'USD' | yes | |
| amount | string | yes | A decimal string, e.g. "19.99" — never a number, and never minor units/cents. A JSON number round-trips through a double (19.99 can arrive as 19.989999999999998); a string keeps the amount you meant. See "Amounts" below. |
| dueDate | string | yes | yyyy-MM-dd, as a Bolivian calendar day. Use currentDate() / datePlusDays() below rather than computing it yourself. |
| singleUse | boolean | no | Defaults to true. false makes a reusable QR shared by many payers — and the type system then forbids payerName on it (a name on a shared QR would show every later payer the first one's name). |
| payerName | string | no | Single-use charges only; see above. |
| modifyAmount | boolean | no | Lets the payer change the amount in their banking app. Defaults to false. |
| description | string | no | |
| branchCode | string | no | The bank caps this at 5 characters. |
| metadata | Record<string, unknown> | no | Echoed back untouched, never sent to the bank. |
| successUrl / cancelUrl | string | no | Absolute http(s) URL. Ayni appends ayni_ref=<publicToken> when it returns the payer. |
| accountId | string | no | Which account to credit. Omit to use the tenant's default for the given currency. |
Returns Promise<CreateChargeResult>:
| kind | Meaning |
| ---------------- | ------------------------------------------------------------------------------------------------------------------- |
| 'ok' | charge: Charge — see below. |
| 'conflict' | reference exists with different terms. Not retryable as-is. |
| 'unauthorized' | Key missing, malformed, revoked, or not permitted this mode. |
| 'invalid' | Terms rejected; message names the field. |
| 'rate-limited' | Back off. |
| 'unavailable' | Network failure, timeout, or unexpected status. Retry with the same reference — the charge may already exist. |
Charge (the ok payload)
The full shape returned on success: id, reference, publicToken (safe to
hand the payer — it is not id), checkoutUrl (redirect the payer here),
status, amount, amountMinor, currency, singleUse, modifyAmount,
dueDate, description, branchCode, providerChargeId, qrImageUrl,
createdAt, settledAt, metadata, successUrl, cancelUrl, payerName,
and payments.
payments is the one field to keep server-side. Each entry carries
senderName, senderBankCode, and senderAccountMasked — the bank identity
of whoever paid. Returning this array wholesale from your own API route
publishes every payer's name and masked account to whoever loads the page.
Pick the fields you actually need and forward those, not the object.
Amounts
amount on the way in (CreateChargeInput.amount) is a decimal string in
major currency units — "250.00" means 250 BOB, not 25000 centavos.
Sending minor units here overcharges the payer by 100×.
The Charge you get back also includes amountMinor, an integer in minor
units (100 minor units = 1 major unit, for both BOB and USD) alongside the
same amount as a decimal string. Only amountMinor is the API's internal
integer representation; amount is what you sent.
Bolivia date helpers
import { currentDate, datePlusDays } from '@aynicobros/node';
currentDate(); // "2026-09-17", as read on a wall clock in La Paz right now
datePlusDays(3); // 3 calendar days from today, same ruledueDate needs a Bolivian calendar day, and the obvious one-liner —
new Date().toISOString().slice(0, 10) — is UTC. Bolivia is UTC-4 with no
daylight saving, so between 20:00 and midnight local time that expression
already reports tomorrow's date; a charge created then gets the wrong due
date, and the mistake never throws, only shows up later as a charge expiring
a day early or late. These two helpers exist so you don't have to get that
right yourself.
Other exports
CURRENCIES (['BOB', 'USD']) and its Currency type, AYNI_API_ORIGIN,
DEFAULT_TIMEOUT_MS, and the ClientConfig / AyniCobrosClient /
CreateChargeInput / CreateChargeResult types.
Next step: reading the outcome
Once the payer lands back on your successUrl, read ayni_ref from the
query string and pass it to @aynicobros/js's getCheckoutStatus — from
your browser code, with no secret involved. See that package's README
for the polling cadence to use while you wait for a status to settle.
