Merchant setup guide
AFA.CO lets your store accept USDT and USDC on BNB Smart Chain (BEP-20). Every payment is split on-chain: 99.5% lands in your own wallet, 0.5% is the fee. Wallet payments are split in the payment transaction itself; QR deposits are split by the invoice's forwarder contract right after they arrive. AFA never holds your money, so there is nothing to withdraw and nothing that can be frozen on our side.
Create your account
On the sign-up page enter your business name, email and a password of at least 10 characters. The account is active at once: no documents and no review, because AFA never holds your money and every payment goes straight to your own wallet. You can choose the handle of your public page there, or later in Dashboard → Settings.
Set your payout wallet
In Dashboard → Payout wallets, enter your BNB Smart Chain address (it starts with 0x). 99.5% of every payment lands there directly. Changing it asks for your password again, so a stolen session can't redirect your money.
- Use a wallet whose keys you hold: Trust Wallet, MetaMask, SafePal, Ledger, or a multisig Safe.
- Copy and paste the address, never type it. A mixed-case address is checksum-verified, so a typo is refused.
- Blockchain payments can't be reversed: before you start, make a 1 USDT test payment and see it arrive in your wallet.
Choose how customers pay
Pick one or more, depending on your business. They all pay the same wallet.
Connect your website (API)
Create an API key in Dashboard → Developers and keep it on your server only (never in website or app code). At checkout your server creates an invoice and sends the buyer to checkoutUrl. Put your own order id in reference: it comes back in the webhook (a whole-number id is accepted too, and always comes back as text). Save each invoice's id (data.id) with your order id and its amount: that is how the webhook finds the order.
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",
"reference": "order-1042",
"redirectUrl": "https://yourshop.com/thanks?order=1042"
}'const res = await fetch("https://pay.afa.co.com/api/v1/invoices", {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": process.env.AFA_API_KEY },
body: JSON.stringify({
chainId: 56,
token: "USDT",
amount: "25.00",
reference: String(order.id), // your order id; comes back (as text) in the webhook
redirectUrl: `https://yourshop.com/thanks?order=${order.id}`,
}),
});
const { ok, data, error } = await res.json();
if (!ok) throw new Error(error.message);
// One row per invoice: a buyer who retries gets a new one, and either can still be paid. amountWei is text.
await db.afaInvoices.insert({ id: data.id, orderId: order.id, amountWei: data.amountWei });
// send the buyer to data.checkoutUrl (or data.telegramUrl inside Telegram)$ch = curl_init("https://pay.afa.co.com/api/v1/invoices");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json", "x-api-key: " . getenv("AFA_API_KEY")],
CURLOPT_POSTFIELDS => json_encode([
"chainId" => 56, "token" => "USDT", "amount" => "25.00", "reference" => (string)$orderId,
]),
]);
$res = json_decode(curl_exec($ch), true);
if (!$res["ok"]) { die($res["error"]["message"]); }
// One row per invoice: a buyer who retries gets a new one, and either can still be paid. amountWei is text.
save_afa_invoice($res["data"]["id"], $orderId, $res["data"]["amountWei"]);
header("Location: " . $res["data"]["checkoutUrl"]);Confirm payments with webhooks
In Dashboard → Developers → Webhooks, add your server's URL, choose the events, and store the signing secret (shown once). When a payment is final on-chain, AFA sends your server a signed request.
- Mark an order paid only from a signed webhook (or a server-side
GET /api/v1/invoices/:id), never from the browser returning toredirectUrl. - Verify the signature over the raw request body, and use the
AFA-Delivery-Idheader to ignore repeats. - Find the order by the invoice id you saved (
data.id) and compare the amount (data.amountWei). Never mark an order paid fromdata.referencealone: anyone can mint a small invoice on a public payment link. - A payment sent before the deadline can confirm after it:
invoice.paidcan arrive up to 72 hours after an invoice expired. invoice.underpaidmeans a wallet payment of at least 1% of the invoice amount, but less than all of it, arrived: hold the order and talk to the customer. A payment below 1% is ignored and the invoice stays open for payment. A short QR deposit doesn't send it (see step 9).- Using recurring billing? Subscribe to
invoice.createdandinvoice.overduetoo.
import crypto from "node:crypto";
import express from "express";
app.post("/afa-webhook", express.raw({ type: "application/json" }), async (req, res) => {
const raw = req.body.toString("utf8"); // the RAW body, before JSON.parse
if (!verify(raw, req.get("AFA-Signature") ?? "", process.env.AFA_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const deliveryId = req.get("AFA-Delivery-Id"); // same id on every retry
if (await alreadyHandled(deliveryId)) return res.sendStatus(200);
const { event, data } = JSON.parse(raw);
if (event === "invoice.paid") {
// Find the order through the invoice id you saved, never by data.reference
// alone: anyone can mint a small invoice on a public link.
const saved = await db.afaInvoices.findOne({ id: data.id });
if (saved && data.amountWei === saved.amountWei) {
await markOrderPaid(saved.orderId, { invoiceId: data.id, txHash: data.txHash });
}
}
res.sendStatus(200); // answer fast; do slow work after
});
function verify(raw, header, secret, toleranceSec = 300) {
const p = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const t = Number(p.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = Buffer.from(crypto.createHmac("sha256", secret).update(`${t}.${raw}`).digest("hex"), "hex");
const given = Buffer.from(p.v1 ?? "", "hex");
return expected.length === given.length && crypto.timingSafeEqual(expected, given);
}$raw = file_get_contents("php://input");
parse_str(str_replace(",", "&", $_SERVER["HTTP_AFA_SIGNATURE"] ?? ""), $sig);
$expected = hash_hmac("sha256", ($sig["t"] ?? "") . "." . $raw, getenv("AFA_WEBHOOK_SECRET"));
if (!isset($sig["t"], $sig["v1"]) || abs(time() - (int)$sig["t"]) > 300 || !hash_equals($expected, $sig["v1"])) {
http_response_code(400); exit;
}
if (already_handled($_SERVER["HTTP_AFA_DELIVERY_ID"] ?? "")) { http_response_code(200); exit; }
$event = json_decode($raw, true);
if ($event["event"] === "invoice.paid") {
// Find the order through the invoice id you saved, never by the reference alone.
$data = $event["data"];
$saved = find_afa_invoice($data["id"]);
if ($saved && $data["amountWei"] === $saved["amount_wei"]) {
mark_order_paid($saved["order_id"], $data["id"]);
}
}
http_response_code(200);Recurring billing and reminders
In Dashboard → Recurring, enter a title, amount, period (daily, weekly, monthly) and the customer's email. Every period AFA issues a fresh invoice, emails it, reminds the customer before the deadline, and tells you if it goes unpaid: in Telegram if you connected the bot, otherwise by email, and with the invoice.overdue event to webhooks subscribed to it. Nothing is pulled from the customer's wallet: they pay each period themselves. When the customer's messages are in Persian, monthly schedules follow Persian (Solar Hijri) calendar months in Tehran time by default.
Send the customer the Telegram link shown on each schedule: after one tap on Start, invoices and reminders arrive in their Telegram with a pay button that works inside Telegram. Schedules can also be created through the API:
curl -X POST https://pay.afa.co.com/api/v1/recurring \
-H "Content-Type: application/json" -H "x-api-key: afa_live_..." \
-H "Idempotency-Key: member-77" \
-d '{
"title": "Monthly membership",
"amount": "10",
"interval": "month",
"customer": { "email": "sara@example.com", "name": "Sara" },
"lang": "fa",
"reference": "member-77"
}'
# -> 201 { "ok": true, "data": { ...schedule, "telegramLink": "https://t.me/...",
# "firstInvoice": { ...invoice, "checkoutUrl": "...", "telegramUrl": "..." } } }
# firstInvoice is null when startAt is in the future, or when issuing it failed (AFA retries it).
# The same Idempotency-Key again -> 200 with this same schedule, never a second one.
# "lang": "fa" -> months follow the Persian calendar (Tehran time) unless you send "calendar": "gregorian".Refunds
The money is in your wallet, so you send the refund; AFA prepares it and then verifies it. In Dashboard → Payments, open the payment → Refund… → confirm the amount → sign with your payout wallet (or send it from that wallet with any app and paste the transaction hash). From the moment it is prepared, a refund holds its amount until you send it or cancel it, so the same money is never set aside for two refunds. A refund only counts when it comes from the payout wallet this payment went to. AFA checks the transfer on-chain, sends the invoice.refunded event to the webhooks that ticked it (a new endpoint doesn't start with it), tells you, and emails the buyer if their email is on the invoice.
Send each refund as its own transaction: one transaction backs one refund. Sending USDT or USDC from a Safe or another smart-contract wallet, paste the final on-chain transaction hash (the one BscScan shows), not the wallet's own internal id. Before you send, AFA looks on the network and, if it sees a matching transfer, offers to record that one. It can't see a transfer still waiting in a wallet, so check the wallet's history too before sending again.
# 1. prepare: AFA returns the exact transfer for YOU to sign. From now on the refund holds
# its amount (no other refund can take it) until you send it or cancel it.
curl -X POST https://pay.afa.co.com/api/v1/invoices/inv_xxx/refunds \
-H "Content-Type: application/json" -H "x-api-key: afa_live_..." \
-d '{ "amount": "10", "toAddress": "0xCustomerWallet..." }'
# toAddress is the customer's own wallet. Leave it out only when one wallet paid the whole
# invoice with Connect wallet (the refund then goes back to that wallet). It is required for
# a QR or exchange deposit, or a payment made in several transfers.
# -> 201 { "ok": true, "data": { "id": "rf_...", "status": "PENDING", "senders": ["0xYourPayoutWallet"],
# "transaction": { "to": "0x55d3...7955", "data": "0xa9059cbb...", "value": "0" } } }
# 2. send that transaction FROM THE PAYOUT WALLET THE PAYMENT WENT TO (no other sender counts),
# then record its hash
curl -X POST https://pay.afa.co.com/api/v1/refunds/rf_xxx/submit \
-H "Content-Type: application/json" -H "x-api-key: afa_live_..." -d '{ "txHash": "0x..." }'
# -> { "ok": true, "data": { "id": "rf_...", "status": "SUBMITTED", ... } }
# AFA verifies the transfer on-chain, then sends you invoice.refunded
# and emails the buyer if their email is on the invoice
# Not sending it after all? Cancel it to free the amount:
curl -X POST https://pay.afa.co.com/api/v1/refunds/rf_xxx/cancel -H "x-api-key: afa_live_..."Payments inside Telegram
When AFA's Telegram Mini App is on, every invoice also has a telegramUrl that opens the checkout inside Telegram itself (in any chat, group or channel). Payment links and your storefront have one too, shown next to each in Dashboard → Buttons & Links. From Telegram the buyer opens the payment in their wallet app or pays from the Scan QR tab, and can then send the receipt to their Telegram from the paid screen. If you run your own Mini App:
// Inside your own Telegram Mini App, after your server created the invoice: if (invoice.telegramUrl) Telegram.WebApp.openTelegramLink(invoice.telegramUrl); else Telegram.WebApp.openLink(invoice.checkoutUrl);
Buyers without BNB
- Paying from a wallet needs a little BNB for the network fee. When a buyer holds USDT or USDC but no BNB, the checkout offers to have AFA cover it: they sign a free message and AFA sends the BNB that this payment's network fee needs.
- AFA offers this only when the option is 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 a customer has paid at least once (10 USDT or more in one invoice, from the customer's own wallet through the Connect wallet tab; a QR deposit doesn't count), and not for smart-contract wallets (such as a Safe). So your 1 USDT test payment won't show the offer.
- The other route is the Scan QR tab: the buyer withdraws straight from an exchange, and the exchange pays the network fee.
- Important: most exchanges subtract their withdrawal fee from the amount sent, but what ARRIVES must equal the invoice. A short deposit leaves the invoice Detected (status
DETECTED) with the amount received, and noinvoice.underpaidwebhook is sent; the checkout asks the buyer to send the rest to the same address. Hold the order until it's paid, and check it withGET /api/v1/invoices/:id. - Money that reaches a deposit address outside its invoice doesn't settle it: a second payment after the invoice was paid, a short deposit still not completed 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). Another token waits at the address until the invoice is paid or closed. Extra money on a paid invoice can be refunded from Payments; anything else you return from your own wallet.
Go-live checklist
- You tested your payout address end to end with a real 1 USDT payment.
- Your server verifies webhook signatures and rejects requests without a valid one.
- The API key lives only on your server: not in a code repository, not in a mobile app.
- Your server finds each order by the invoice id it saved, not by the reference alone, and an order can't be marked paid twice.
- You know what to do with EXPIRED (nothing was paid; invoice.paid still comes if money lands later), UNDERPAID (part of the money is in your wallet: ask for the rest or refund it), short QR deposits (they stay DETECTED) and late payments.
- You connected the Telegram bot so you see every sale as it happens.
Security
- AFA never asks for your wallet's recovery phrase (seed) or private key. Anyone who does is a scammer.
- Use a strong, unique password; changing it signs out every other device.
- If an API key or webhook secret leaks, revoke it in the dashboard and create a new one.
- Every payment has an on-chain-verifiable receipt at
/receipt; share it with your customer.
Questions? afa.developersteam@gmail.com
