@aynicobros/js
v0.2.0
Published
Headless client for the ayni cobros hosted checkout. Reads a charge by its public token, and owns the polling decisions behind it.
Downloads
873
Maintainers
Readme
@aynicobros/js
Browser-safe client for reading an ayni cobros charge's status by its public token — plus the polling cadence and status predicates ayni's own checkout page uses, so you don't have to rediscover them.
Where this fits
An ayni integration is two packages across a redirect:
@aynicobros/node(on the merchant's server) creates a charge with the merchant's secret key and gets back acheckoutUrland apublicToken.- The payer is sent to
checkoutUrl. Ayni hosts the payment page — QR, bank polling, "ya transferí" — and returns them to the merchant'ssuccessUrlwithayni_ref=<publicToken>appended. - This package, in the browser, reads the outcome with
getCheckoutStatusand decides what to do next.
This package needs no secret. The credential is the token itself: a 128-bit
capability the payer was already handed, good for the terms and status of
that one charge and nothing else. If something here ever appears to want an
API key, that would be the wrong design — creating a charge belongs on the
server, in @aynicobros/node.
Install
npm install @aynicobros/jsShips as ESM only. getCheckoutStatus uses fetch, AbortSignal.timeout,
and AbortSignal.any — evergreen browsers, or a polyfill for AbortSignal.any
on older ones.
Quick start
import { getCheckoutStatus, outcomeOf, isRetryable } from '@aynicobros/js';
const token = new URLSearchParams(location.search).get('ayni_ref');
if (!token) {
throw new Error('missing ayni_ref on the return URL');
}
const result = await getCheckoutStatus(token);
switch (result.kind) {
case 'ok': {
const outcome = outcomeOf(result.checkout.status);
if (outcome === 'paid') {
// The only state in which you may release the order.
} else if (outcome === 'awaiting') {
// Not paid YET, and it still can be — hold the order. Do not cancel it.
} else {
// 'unpaid': nothing is owed and nothing will arrive. Safe to release
// the reservation. If isRetryable(result.checkout.status), the SAME
// order can be charged again (only true for REJECTED).
if (isRetryable(result.checkout.status)) {
// Offer to retry this order.
}
}
break;
}
case 'not-found':
// Unknown token, malformed token, or a charge whose QR was never minted —
// all three answer identically, so this can't be used to enumerate tokens.
break;
case 'rate-limited':
// Back off — see "Polling" below before you retry.
break;
case 'unavailable':
// Network failure, timeout, or unexpected status. Retryable, never terminal.
break;
}getCheckoutStatus never throws and always settles — it does not hang on a
dead connection, which is the ordinary outcome of a mobile-network handoff
mid-request, not a corner case. CheckoutResult is a discriminated union;
handle every kind. A handler that only reads result.checkout on the happy
path breaks on the other three, and not-found in particular is a normal
answer, not a bug.
Status: use the predicates, not your own switch
A charge's status is one of seven wire values
(UNRESOLVED | PENDING | COLLECTING | PAID | EXPIRED | CANCELLED | REJECTED),
and several of them are easy to misread: REJECTED looks final and is not;
COLLECTING looks like an error and is a healthy reusable QR; and treating
"not PAID" as "failed" tells a payer their money vanished while the bank is
still settling. Use the exported predicates instead of writing your own:
| Function | Answers |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| isPaid(status) | Money confirmed. The only value this is true for is PAID — not a range. On a reusable QR, COLLECTING means somebody paid, never that your payer did. |
| isPayable(status) | The payer can still complete this payment (PENDING or COLLECTING). |
| isTerminal(status) | The charge will not change again (PAID, EXPIRED, or CANCELLED) — not the same question as success. An expired charge is terminal and unpaid. |
| isRetryable(status) | The same order reference may be charged again. True only for REJECTED, where the bank created nothing. EXPIRED/CANCELLED need a new reference, or you risk two live codes for one order. |
| outcomeOf(status) | Collapses all seven statuses into the three decisions that actually matter: 'paid', 'awaiting', or 'unpaid'. Prefer this over the four predicates above when what you want is "what do I do now". |
| isChargeStatus(value) | Type guard — use before trusting a raw string as a ChargeStatus. |
| isLive(mode) | Whether a ChargeMode ('TEST' \| 'LIVE') can move real money. |
outcomeOf is the one to reach for first: 'awaiting' covers PENDING,
COLLECTING, and UNRESOLVED — the bank hasn't confirmed the QR even
exists yet, which is not the same as unpaid, and nightly reconciliation is
what eventually resolves it. Cancelling an order on 'awaiting' is the
mistake this exists to prevent.
Checkout shape
The ok result carries a checkout: Checkout with status, mode
('TEST' | 'LIVE' — a TEST charge is unscannable by a real bank and
collects nothing; shipping goods on one is shipping for free), amount (a
decimal string, e.g. "19.99" — floats lose cents), currency,
description, dueDate, modifyAmount, singleUse, qrImageUrl,
settledAt, successUrl, cancelUrl, and three fields worth extra care:
singleUse— load-bearing for "did my payer pay". On a reusable QR the status is shared across every payer of that code, so no status alone can answer that question for one payer.merchant/account— optional and nullable, even though the API always sends them today. There's no runtime validation on the response, and a store pins a version of this package and upgrades on its own schedule — an old bundle meeting a newer API projection is the normal state, not a bad deploy. Always optional-chain (checkout.merchant?.name), never assume presence.payerName— the name the merchant set for the payer, single-use charges only. Never collected from the payer by this package.
Polling: pick the window for what's on screen
This package does not run a polling loop for you. It exports one read
(getCheckoutStatus) and the constants below — the same cadence ayni's own
hosted checkout page uses — so that whatever loop you write doesn't have to
rediscover values that have already caused real incidents when set wrong.
Every constant is a default: pass your own interval to the loop you build,
don't hardcode these.
| Situation on screen | Constant | Value | Why |
| ------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Nothing yet — a QR sitting there, no button pressed | BACKGROUND_POLL_MS | 30 s | Cheap for the server (an indexed read, never the bank), but not free for your rate-limit bucket. Give up after BACKGROUND_POLL_WINDOW_MS (10 min) and offer a manual retry instead of polling an abandoned tab forever. |
| The payer just clicked "I already paid" | CONFIRM_POLL_MS / CONFIRM_WINDOW_MS | 5 s, for up to 60 s | This path calls the bank on ayni's side, so it's faster but time-boxed — a payment the bank hasn't reported yet isn't a payment that failed, so stop and show a "contact support" state rather than declaring failure. |
| The page gives the payer their own manual control (a visible QR with a confirm button, or "Reintentar") | CONFIRMABLE_POLL_MS | 60 s | Slower, not off: the control beside it can ask the bank directly on demand, so the background read only has to catch a webhook or a merchant-side settlement. This is also the most common page in the product, so its cadence is what decides what a crowd behind one shared address costs you. |
| You just got a 429 | BACKOFF_AFTER_429_MS | 30 s, as a floor | Take Math.max(BACKOFF_AFTER_429_MS, yourNormalInterval) — never this value on its own. Used as a flat replacement, it has sped up a 60 s poller to 30 s right after the bucket it just hit told it to slow down. |
Rate limits, and why the numbers above are what they are:
API_CHECKOUT_READ_LIMIT_PER_MINUTE (30/min) applies to the plain status
read above; API_CHECKOUT_REFRESH_LIMIT_PER_MINUTE (180/min) applies to the
bank-backed confirm call, which is a separate, more expensive bucket. Both
are sized against CLIENTS_PER_ADDRESS (10) — CGNAT means one public IP on
a Bolivian mobile network is routinely ten different payers, not one, so a
cadence that looks safe for a single browser can 429 an entire shared
address. Polling faster than the table above risks exactly that.
CORS
Browser calls from this package are subject to a per-merchant allowlist held in ayni's own dashboard. Register your origin there first — until you do, requests are blocked before this code ever runs, and the SDK will look broken when the missing piece is configuration.
Other exports
AYNI_API_ORIGIN, DEFAULT_READ_TIMEOUT_MS (15 s — this is an indexed
database read, never the bank, so anything slower is a lost connection, not
a slow answer), CHARGE_STATUSES, CHARGE_MODES, and the ChargeStatus /
ChargeMode / PaymentOutcome / Checkout / CheckoutMerchant /
CheckoutAccount / CheckoutResult / GetCheckoutStatusOptions types.
Previous step: creating the charge
If you don't yet have a token to check, start with
@aynicobros/node on your server — it creates the charge and hands you the
checkoutUrl to send the payer to in the first place.
