API Documentation

Accept non-custodial USDT and USDC payments on BNB Smart Chain. One REST call mints a hosted checkout; funds settle straight to your wallet with a flat 0.5% fee split on-chain. New here? The step-by-step setup guide (English and Persian) walks through every integration.

Quickstart

Create an invoice and redirect your customer to the returned checkout URL:

curl -X POST https://pay.afa.co.com/api/v1/invoices \
  -H "Content-Type: application/json" \
  -H "x-api-key: afa_live_..." \
  -d '{
    "chainId": 56,
    "token": "USDT",
    "amount": "25.00",
    "method": "WALLET",
    "reference": "order-1042",
    "redirectUrl": "https://yourshop.com/thanks"
  }'
{
  "ok": true,
  "data": {
    "id": "inv_9fc9e5a2e57096fcbf1c0d4be810b9aa",
    "status": "PENDING",
    "checkoutUrl": "https://pay.afa.co.com/pay/inv_9fc9...",
    "telegramUrl": "https://t.me/<bot>/<app>?startapp=inv_9fc9...",  // null until the Mini App is set up
    "forwarderAddress": "0x62412Ab7...",   // per-invoice QR deposit address
    "amountWei": "25000000000000000000",
    "payoutAddress": "0xYourWallet...",
    "feeBps": 50,
    "reference": "order-1042",
    "expiresAt": "2026-07-18T16:01:05.477Z"
  }
}

The hosted checkout offers both wallet-connect and an exchange-style QR deposit. When the payment settles on-chain you receive a signed invoice.paid webhook, and the customer is redirected to your redirectUrl. Fulfil the order from the webhook, never from the redirect: anyone can open a redirect URL.

Authentication

Create an API key in Dashboard → Developers and send it with every request:

x-api-key: afa_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Keys are shown once at creation and stored hashed; revoke any key instantly from the dashboard. There is no test network: every key creates real invoices on BNB Smart Chain mainnet, so test your integration with a small real payment (for example 1 USDT). Keep keys on your server only.

A missing or wrong key answers 401 (unauthorized). A key of a suspended account answers 403 (account_suspended).

Invoices

POST/api/v1/invoices

Mint a single-use invoice. Body fields:

chainId               56                   // BNB Smart Chain
token                 "USDT" | "USDC"      // BEP-20 on BNB Smart Chain
amount                "25.00"              // human decimal string
method?               "WALLET" | "QR"      // checkout default tab (both are offered)
reference?            "order-1042"         // your own order id: up to 128 characters, or a whole number
redirectUrl?          "https://..."        // browser redirect after payment
cancelUrl?            "https://..."        // the checkout's "Back to the shop" link (see below)
metadata?             { ... }              // your own data: back in this answer, your list and webhooks, never on the public status endpoint
ttlMinutes?           1440                 // payment window, 1 minute to 30 days (default 10, or 1440 = 24 h with sendEmail)
customerEmail?        "buyer@example.com"  // receipt on payment; invoice email with sendEmail
sendEmail?            true                 // email the payment link to customerEmail now
remindBeforeMinutes?  120                  // remind customerEmail this long before the deadline
lang?                 "en" | "fa"          // language of those emails

The reference is shown on the receipt and sent in webhooks. A whole-number reference is stored as text, so it comes back as a string. Send whole numbers above 2^53 − 1 (9007199254740991) as strings: a JSON number that large has already lost digits, so it is refused with 422. With sendEmail and no ttlMinutes, the invoice stays payable for 24 hours instead of 10 minutes, so the emailed link is still valid when the customer opens it. The reminder must fall at least 5 minutes after the invoice is created: a remindBeforeMinutes that leaves less (say 120 with a 60-minute window) is refused with 422 rather than silently dropped.

With sendEmail, the response also carries emailSent. false means the email hasn't gone out yet, not that it never will: AFA tries it again a few times while the invoice is open, and an email held back by a daily limit on customer emails (your shop's own, or AFA's limit across shops) goes out after the limits reset at 00:00 UTC, if the invoice is still open then. Your shop's own limit is 20 a day until customers have paid it 10 USDT or more in all, then 300 (payments from your own payout wallet don't count; AFA looks at your shop's first 500 and latest 50 paid invoices).

The checkout shows cancelUrl as its "Back to the shop" link only when it is an https URL (plain http only for localhost). The link is hidden while a payment is confirming, once the invoice is paid, and inside the widget's frame, whose close button already leads back to your page.

GET/api/v1/invoices/:id

Public, unauthenticated status endpoint, safe to poll from the browser, on your own site too (open CORS): returns the same invoice object as the create call, checkoutUrl and telegramUrl included, with live status, receivedWei, txHash, paidAt, refundedWei and voidedAt. closed is true when an unpaid invoice can no longer be paid: it is past its late-payment window, its part payment was refunded, or the shop is suspended.

GET/api/v1/invoices?status=PAID&limit=25

Your latest invoices (API key), newest first, optionally filtered by status (an unknown one answers 422); limit is 1 to 100 (default 25). Same invoice objects, with your metadata.

POST/api/v1/invoices/:id/void

Withdraw an invoice you no longer want paid (the order was cancelled, the price changed). Only a PENDING invoice with no deposit seen can be voided: it becomes EXPIRED at once, voidedAt records when, and its checkout no longer offers payment. Voiding a voided invoice again returns it with 200; a paid, part-paid or already expired invoice gets 409, and an unknown id (or another account's invoice) 404. A payment already on its way is still recorded, so keep handling invoice.paid for a voided invoice (refund it if the order is gone). The checkout tells a buyer who opens a voided invoice that the seller withdrew it.

→ { "ok": true, "data": { ...invoice, "status": "EXPIRED", "voidedAt": "2026-07-18T16:20:00.000Z" } }

Widget & buttons

Drop-in checkout for any website — no backend required:

<script src="https://pay.afa.co.com/widget.js" defer></script>

<!-- reusable button created in the dashboard (copy it from there, with its id) -->
<button data-afa-button="YOUR_BUTTON_ID">Pay with AFA.CO</button>

<!-- a button with no amount of its own: the payer types it in a field of yours -->
<input id="amount" type="number" min="0" step="any" />
<button data-afa-button="YOUR_BUTTON_ID" data-afa-amount-input="#amount">Pay</button>

<!-- or your page sets it -->
<button data-afa-button="YOUR_BUTTON_ID" data-afa-amount="27.5">Pay 27.5</button>

<!-- or open a specific invoice you minted server-side (its full checkoutUrl) -->
<a data-afa-checkout="https://pay.afa.co.com/pay/inv_xxxxxxxx">Complete payment</a>

A button created with no amount needs one of the two: without it, a click only says the button requires an amount. The other attributes are optional: data-afa-lang, data-afa-theme, data-afa-reference and data-afa-redirect.

The widget opens the hosted checkout in an overlay iframe and emits afaPay postMessage events (status, paid) to the host page. For security the checkout never broadcasts these events to "*": it targets only your origin. Either load it with ?embedOrigin=https://your-site.com, or complete the handshake — the iframe posts { afaPayReady: true } on load, and your page replies with { afaPayInit: true } so the checkout learns (and pins) your exact origin.

Webhooks

Add an endpoint in Dashboard → Developers and choose its events; you receive a signing secret (shown once). Events are POSTed as JSON:

POST https://yourshop.com/afa-webhook
AFA-Event: invoice.paid
AFA-Delivery-Id: cm2x...          // same value on every retry: dedupe on it
AFA-Signature: t=1789040000,v1=8f2a…c41

{
  "event": "invoice.paid",
  "data": { /* invoice object, same shape as GET /invoices/:id (checkoutUrl, telegramUrl included) */ },
  "recurring": { "id": "rec_...", "cycle": 3 },   // only on a recurring schedule's invoices
  "refund": { /* refund object */ },               // only on invoice.refunded
  "createdAt": "2026-07-18T15:58:09.000Z"
}

Verify the signature — HMAC-SHA256 over `${t}.${rawBody}` (the raw body, before parsing):

import crypto from "node:crypto";

function verify(rawBody, header, secret, toleranceSec = 300) {
  const { t, v1 } = Object.fromEntries(header.split(",").map(p => p.split("=")));
  if (!Number(t) || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
  const expected = Buffer.from(
    crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"), "hex");
  const given = Buffer.from(v1 ?? "", "hex");
  // timingSafeEqual throws on unequal lengths, so compare lengths first
  return expected.length === given.length && crypto.timingSafeEqual(expected, given);
}
invoice.paid        settled on-chain: fulfil the order
invoice.underpaid   a wallet payment brought at least 1% but less than the amount (a short QR deposit sends nothing: see Statuses)
invoice.created     a recurring schedule issued this period's invoice
invoice.overdue     a recurring invoice passed its deadline unpaid (it may still be paid late)
invoice.refunded    a refund you sent was confirmed on-chain

invoice.paid usually carries the transaction. When the buyer's own checkout finds the payment first, it waits up to 90 seconds for the transaction before it goes out, so it can come a little after the checkout says paid. A delivery that keeps failing is listed under Developers, with a Resend button, and you are told about it.

An endpoint receives only the events it is subscribed to; endpoints created before event selection existed keep invoice.paid + invoice.underpaid. Using recurring billing? Subscribe to invoice.created and invoice.overdue too. Deliveries retry with backoff (up to ~6 hours) until acknowledged with a 2xx. An account can have at most 10 active endpoints.

Recurring billing

A schedule issues a fresh single-use invoice every period, emails it to the customer (and to their Telegram once they connect it), reminds them before the deadline, and reports unpaid periods. Nothing is pulled from the customer's wallet: each period is paid like any other checkout.

POST/api/v1/recurring
Idempotency-Key: member-77-signup          // header, recommended: a retry can't create a second schedule

title               "Monthly membership"   // shown to the customer
amount              "10"                   // per period, human decimal
token?              "USDT" | "USDC"     // default "USDT"
chainId?            56
interval            "day" | "week" | "month"
intervalCount?      1                      // e.g. 3 + "month" = quarterly
calendar?           "gregorian" | "persian" // how months are counted (default: persian when lang is "fa")
startAt?            "2026-11-01T09:00:00Z" // first invoice; omitted = now (more than 5 min in the past: 422)
dueHours?           72                     // time to pay each invoice
remindBeforeHours?  24                     // 0 = no reminder
maxCycles?          12                     // stop after N invoices
endsAt?             "2027-11-01T00:00:00Z" // no invoice after this
customer?           { "email": "...", "name": "..." }
reference?          "member-77"            // invoices are referenced "member-77#1", "#2", ...
metadata?           { ... }
lang?               "en" | "fa"            // language of the customer's messages

→ 201 { "ok": true, "data": { ...schedule, "telegramLink": "https://t.me/<bot>?start=r_...",
        "firstInvoice": { ...invoice, "checkoutUrl": "...", "telegramUrl": "..." } | null } }

firstInvoice is null when startAt is in the future, or when issuing the first invoice failed: the schedule exists either way and the watcher retries the cycle, so don't create it again. startAt and endsAt must be before 2100-01-01 (UTC); a later date is refused with 422. The persian calendar steps Solar Hijri months in Tehran time; gregorian steps calendar months in UTC (day and week intervals are the same in both).

The Idempotency-Key is 1 to 100 characters of A-Z a-z 0-9 _ . : - (anything else is refused with 422). A request repeated with the same key returns the schedule that key already created, with 200 instead of 201, without comparing the body: a different schedule needs a new key.

// a schedule, as every recurring endpoint returns it
{
  "id": "rec_...",
  "status": "ACTIVE" | "PAUSED" | "CANCELLED" | "COMPLETED",
  "title": "Monthly membership", "amount": "10", "token": "USDT",
  "interval": "month", "intervalCount": 1, "calendar": "persian",
  "dueHours": 72, "remindBeforeHours": 24,
  "nextRunAt": "2026-12-01T05:30:00.000Z",    // when the next invoice is issued (null unless ACTIVE)
  "cyclesIssued": 3, "maxCycles": 12, "endsAt": null,
  "lastInvoiceId": "inv_...",
  "lastError": null,                          // why the last invoice couldn't be issued
  "autoPaused": false,                        // true: AFA paused it after repeated failures
  "customer": { "email": "...", "name": "...", "telegramLinked": true },
  "telegramLink": "https://t.me/<bot>?start=r_...",
  ...
}
GET/api/v1/recurring?status=ACTIVE&limit=25

Your schedules, newest first, optionally filtered by status; limit is 1 to 100 (default 25).

GET/api/v1/recurring/:id

A schedule with its latest invoices (invoices: up to 12, newest first). Each is an invoice object plus its cycle and how its customer messages went: notifyError (why the last one didn't go out: send_failed or daily_email_limit), notifyRetryAt (when the "new invoice" message is tried again), remindAt (when the reminder is due) and voidedAt.

PATCH/api/v1/recurring/:id
{ "action": "pause" | "resume" | "cancel" }
{ "action": "cancel", "voidOpenInvoice": true }   // also void its open invoice if nothing was paid on it
→ { "ok": true, "data": { ...schedule, "voidedInvoiceId": "inv_..." | null } }   // voidedInvoiceId: cancel only

404  "Schedule not found"
409  "This schedule changed in the meantime. Reload and try again."   // another change landed first
422  a change its status doesn't allow (pausing a schedule that isn't active, resuming one that
     isn't paused, anything on a cancelled or completed one), or an invalid body
  • voidedInvoiceId names the invoice a cancel voided. It is null when nothing was voided: voidOpenInvoice wasn't set, no invoice was open, or a payment had already reached it.
  • Resuming a schedule you paused while it was issuing normally bills from its next nominal time: the periods it spent paused are never billed.
  • A schedule paused while it was failing to issue an invoice, by you or by AFA after repeated failures (autoPaused: true), issues that missed invoice on the watcher's next pass after the resume (it runs every minute) if its period is still running. Periods that are completely over are skipped, and the period running now is billed on that pass instead.
  • Resuming a schedule that has issued every cycle (maxCycles) or whose next invoice would fall after endsAt completes it instead.
  • When a period passes its deadline unpaid, AFA tells you in Telegram, or by email if Telegram isn't connected, and sends invoice.overdue.

Refunds

AFA never holds the payment, so a refund is a transfer you send from your own wallet. AFA prepares the exact transaction, then verifies it on-chain before calling it refunded: the transfer must come from the wallet the payment went to (the invoice's payoutAddress, the only allowed sender, returned as senders), reach the refund address, and be mined after the payment (in a later block than the payment when AFA knows its block, otherwise no earlier than about two minutes before AFA recorded it as paid). One transaction confirms one refund in your account. The 0.5% fee is not returned.

POST/api/v1/invoices/:id/refunds
amount?     "10"            // default: everything still refundable
toAddress?  "0x..."         // default: the wallet that paid in full (see below)
reason?     "damaged item"
→ 201 { "ok": true, "data": { "id": "rf_...", "status": "PENDING", "senders": ["0xYourPayoutWallet"],
        "transaction": { "chainId": 56, "to": "<token>", "data": "0xa9059cbb...", "value": "0", "eip681": "ethereum:..." } } }

toAddress defaults to the wallet that paid this invoice in full through the router (the checkout's Connect wallet tab), when exactly one wallet did. Otherwise it is required: QR and exchange deposits, a payment made in several transfers, or while AFA hasn't found the paying wallet yet. Every refusal happens here, before you send anything:

404  invoice not found
409  another refund of yours is being prepared or recorded right now: try again in a few seconds
422  not refundable as asked: the invoice isn't paid or part-paid, nothing is left, the amount is invalid or more
     than what is left, toAddress is missing, invalid or refused (below), or a native-coin refund
     that would have to come from a smart-contract payout wallet
429  20 of your refunds are waiting to be sent or verified: send or cancel some first
     (also: too many requests)
503  a native-coin refund whose payout wallet couldn't be checked on the network: retry in a minute
POST/api/v1/refunds/:id/submit
{ "txHash": "0x..." }       // the transaction you sent
→ { "ok": true, "data": { "id": "rf_...", "status": "SUBMITTED" | "CONFIRMED" | "FAILED", "error": null, ... } }
// invoice.refunded follows on CONFIRMED

A submit that has recorded the hash never answers with an error: when the network can't be asked right now, the refund stays SUBMITTED and the watcher keeps checking it. Before recording it, submit can refuse: 404 for an unknown refund, 422 for a malformed hash or a confirmed or cancelled refund, 409 for a hash another of your refunds already uses (or a refund that changed meanwhile), and 429 for too many requests. Submitting the same hash again checks it again, for a FAILED refund too; a FAILED refund is checked like a new one first (409 and 429 as above, 422 when other refunds have taken its amount).

A refund fails when the network still doesn't know its hash an hour after it was submitted; replacing the hash of a submitted refund doesn't restart that hour. After 30 failed node checks in a row it fails with "Couldn't verify it on the network (…). Check again later."

GET/api/v1/invoices/:id/refunds
→ { "ok": true, "data": {
    "invoiceId": "inv_...", "token": "USDT",
    "paidWei": "...", "refundableWei": "...", "refundedWei": "...",
    "payerAddress": "0x..." | null,          // the wallet that paid in full: the default toAddress
    "payoutAddress": "0xYourPayoutWallet",   // the only wallet a refund may come from
    "senders": ["0xYourPayoutWallet"],
    "refunds": [ { "id": "rf_...", "status": "PENDING", "transaction": { ... }, ... } ] } }

payerAddress is null for QR and exchange deposits, for payments made only in partial transfers, and until AFA has found the paying wallet (ask again a little later). A prepared or failed refund carries its transaction again, so you can send it later without preparing it anew.

POST/api/v1/refunds/:id/cancel

Cancel a prepared or failed refund (cancelling a prepared one frees the amount it reserved); any other refund gets 422. A submitted refund can't be cancelled: if you recorded the wrong hash, submit the right one, which replaces it.

  • A refund holds its amount from the moment it is prepared (PENDING), before you send anything, so two refunds can never promise the same money. Cancelling it frees the amount.
  • For QR and exchange deposits, ask the customer for a BEP-20 address of their own; never refund to the address an exchange paid from.
  • Refused destinations: the AFA router and forwarder factory, the invoice's deposit address, AFA's fee wallet, the token contract, the zero address, and any of your own payout wallets.
  • One transaction per refund: a hash already recorded for one refund can't back another, so send batched refunds (Safe MultiSend, disperse) as separate transactions.
  • From a Safe or another smart-account wallet, submit the final on-chain transaction hash (the one BscScan shows), not the Safe transaction or user-operation hash.
  • A FAILED refund holds nothing. Submitting the same hash again (or a new one) checks it again, as long as other refunds haven't taken that amount in the meantime. Before sending that money again, open the failed refund's transaction on BscScan: if it went through, the customer already has the money, so submit its hash again instead of sending a second transfer.
  • A refund of a native-coin payment can't be sent from a smart-contract wallet.

Telegram Mini App

When AFA's Telegram Mini App is configured, every invoice object carries a telegramUrl (in the create response, GET /invoices/:id, the list, webhook payloads and a recurring schedule's firstInvoice) that opens the same checkout inside Telegram, in any chat. From your own Mini App, open it with Telegram.WebApp.openTelegramLink(telegramUrl). Payment links and your storefront have Mini App links too: copy them from Dashboard → Buttons & Links (they have the form t.me/<bot>/<app>?startapp=l_<slug> and ?startapp=m_<handle>). Inside Telegram the buyer opens the payment in their wallet app or pays from the QR tab, and can have the receipt sent to their Telegram chat from the paid screen.

Buyers without BNB

A wallet payment needs a little BNB for the network fee. When a buyer holds the USDT but no BNB, the hosted checkout can offer to have AFA send the BNB this payment's network fee needs, after the buyer signs a free message proving the wallet is theirs. There is nothing to integrate. It is offered only when AFA has it switched on, and within limits: BNB Smart Chain only, invoices of at least 10 USDT, once per wallet every 30 days, within a daily budget and a per-shop daily limit, for shops at least a week old that have been paid at least once by a customer (10 USDT or more in one invoice, paid with Connect wallet from the customer's own wallet: a QR deposit doesn't count; AFA looks at the shop's first 500 and latest 50 paid invoices), and not for smart-contract wallets. Otherwise the buyer adds a little BNB or pays from the QR tab, where the exchange pays the network fee.

Statuses & errors

PENDING    awaiting payment
DETECTED   funds seen at the QR deposit address: unconfirmed, or a short deposit (receivedWei says how much)
PAID       settled on-chain — 99.5% in your wallet (refundedWei shows any refunds)
UNDERPAID  a wallet payment brought less than the invoice amount (at least 1% of it)
EXPIRED    the window passed without payment, or you voided it (late payments are still reconciled for 72h)

A wallet payment below 1% of the invoice amount changes nothing: the invoice keeps its status and no webhook is sent, so a stray transfer against a public invoice id can't mark your order underpaid.

A short QR deposit (typically an exchange that subtracted its withdrawal fee) leaves the invoice DETECTED with receivedWei and sends no invoice.underpaid webhook; the checkout asks the buyer to send the rest to the same address. Hold the order and poll GET /api/v1/invoices/:id.

Money that reaches a deposit address outside its invoice never settles it: a second payment after the invoice was paid, a deposit left short more than 72 hours after the deadline, or another token. AFA sweeps it to the invoice's payout wallet, less the fee, and tells you on Telegram or by email (under 1 token, or 0.002 BNB, is left alone). No webhook is sent for it, and neither the status nor receivedWei of a paid invoice changes. On a paid invoice, money in its own token raises the refund limit (paidWei in the refund API), so a mistaken payment can be refunded the usual way. Anything else you return from your own wallet, outside AFA. Another token waits at the address until the invoice is paid or closed.

All errors share one shape:

{ "ok": false, "error": { "message": "No payout wallet set for BNB Smart Chain" } }

Every paid invoice has a public receipt at /receipt/<id> — an immutable, on-chain-verifiable proof you can share with customers.

Questions? afa.developersteam@gmail.com