API
Read the catalogue, check live stock, buy and replace accounts, and follow your orders programmatically. Catalogue and stock are public; everything else needs a key, and buying draws on your balance.
Overview
Read the catalogue and live stock, buy accounts, and follow your orders programmatically.
Check the catalogue, read live stock, buy accounts, replace faulty ones and keep your own records in sync, straight from your own tools.
How it works
- Call
GET /api/v1/productsfor the catalogue with your prices and availability, andGET /api/v1/stockfor live stock, optionally filtered with?product=. - Buy with
/api/v1/cs2,/api/v1/rustor/api/v1/extra. The accounts come back in the same response and your balance is charged for what was delivered. - Replace a faulty account yourself with
POST /api/v1/replace, inside the warranty window. No ticket, no staff. - Follow up with
/api/v1/order(one order),/api/v1/orders(all of them) and/api/v1/ledger(every balance movement), or let webhooks tell you when an order changes. - Buys are limited to 30 a minute per account, shared by every key on it. Reads are limited per IP address. See Rate limits.
Base URL
https://www.nfa.pub/api/v1Stock is public and needs no key; products works without one too. Everything else needs a key, and buying charges your balance.
Errors & status codes
Every failure returns a stable code you can branch on.
When a request fails, every response sends a stable code like E1401 in an X-Error-Code header, so you get it whatever the format. With result=json (and always on the JSON-only endpoints) the body carries the same code and an error sentence; with result=text the body is the bare code.
Branch on code and map each one to your own handling. The error sentence says what went wrong in plain words and ends with the reference. It is safe to show your own customers, but do not parse it: the wording may improve, the code will not change.
{
"ok": false,
"code": "E1401",
"error": "Not enough balance on the account this key belongs to. Reference E1401."
}Codes
E1000(HTTP 500): Something went wrong on our side. Retry; if it persists, contact support.E1001(HTTP 401): Your key is missing, malformed, revoked, or unknown.E1002(HTTP 429): You hit a rate limit. Back off and retry shortly. See Rate limits for the numbers.E1003(HTTP 413): The request body exceeded the 4 KB size limit.E1101(HTTP 400): A required parameter was missing, or one was not valid (for example a badlimit, date orcursoron the list endpoints).E1102(HTTP 400): Thetypeparameter is missing or not recognised for this endpoint.E1103(HTTP 400): Quantity must be a whole number between 1 and 100.E1104(HTTP 400): No product matches that id or type.E1201(HTTP 409): The shop is temporarily closed. Try again later.E1202(HTTP 409): The product is misconfigured on our side. Contact support.E1301(HTTP 409): You reused an Idempotency-Key with different parameters.E1302(HTTP 409): A previous call with the same Idempotency-Key is still running. Retry shortly.E1401(HTTP 402): Your balance cannot cover this purchase. Top up, then retry. Nothing was charged.E1501(HTTP 409): We could not source the accounts. Nothing was charged.E1502(HTTP 503): A delivery error occurred and your balance was refunded. Retry.E1503(HTTP 503): Our supplier is briefly unavailable. Nothing was charged. Retry shortly.E1601(HTTP 403): This key is not allowed to do that.E1602(HTTP 404): No order with that id.E1603(HTTP 403): That order was not bought with this key's account, so this key cannot see or act on it.E1604(HTTP 409): That order has not been delivered yet, so there is nothing to replace.E1605(HTTP 409): The replacement window on that order has closed.E1606(HTTP 400): That account is not on that order.E1607(HTTP 409): That account has already been replaced.E1608(HTTP 409): That order has used all of its replacements.E1609(HTTP 409): No replacement in stock right now. Nothing was used up; try again when it restocks.E1610(HTTP 409): Our check says that account still works, so it was not replaced and nothing was used up.E1611(HTTP 503): The account check was inconclusive. Try again in a few minutes.E1612(HTTP 403): Purchases or replacements are blocked for this customer.E1613(HTTP 409): That claim came too long after delivery.E1614(HTTP 409): Held for manual review, so a person has to look at it first.E1615(HTTP 503): Replacements are paused right now.E1616(HTTP 409): A CS2 cooldown is not a fault and lapses on its own, so it is not replaced.E1617(HTTP 409): The warranty on that account was voided by an earlier cooldown or VAC ban claim. Other accounts on the order are unaffected.E1618(HTTP 409): A VAC ban (or a ban after delivery) is not covered, so that account is not replaced.E1619(HTTP 409): That order was refunded in full, so it no longer carries a warranty.E1620(HTTP 409): A chargeback is open on that order, so replacements are paused until it is settled.E1621(HTTP 409): Another replacement on that order is still running. Retry in a few seconds.E1701(HTTP 403): This key is read-only: it can look things up but not buy, replace, fetch credentials in bulk withinclude=accounts, or change webhooks.E1702(HTTP 403): The request came from an IP that is not on your account's IP allowlist.E1703(HTTP 403): You paused API purchases on your account.E1704(HTTP 403): That product is not on your account's allowed products list.E1705(HTTP 400): More accounts in one request than your max per request.E1706(HTTP 403): The order costs more than your per-order spending limit.E1707(HTTP 429): You reached your purchases-per-hour limit.E1708(HTTP 403): The order would go over your daily spending limit. It resets at 00:00 UTC.E1801(HTTP 400): That webhook URL cannot be used. It must behttps://on port 443, a public hostname (not an IP address, a private or reserved name, or one of our domains), at most 500 characters, and resolve only to public addresses. Theerrorsentence says which rule it broke.E1802(HTTP 409): The account already has 3 webhook endpoints. Delete one first.E1803(HTTP 404): No webhook with that id on this account. An id that belongs to another account gets the same answer.E1804(HTTP 503): Webhooks are not available right now, so endpoints cannot be created or their secrets rotated. Nothing was changed; try again later.E1805(HTTP 400): Theeventslist is not valid: it must be a non-empty list of known event types. Theerrorsentence lists them.
Products
GET / POST /api/v1/productsList every product and the price you pay.
Lists all products with the price per account in euro cents. A key is optional. With one, priceCents is exactly what a buy charges right now: a price agreed for your account, or your volume discount, is already in it. Without a key it is the list price. listPriceCents is always the list price.
curl -H "X-Api-Key: rsk_..." "https://www.nfa.pub/api/v1/products?result=json"{
"ok": true,
"count": 2,
"products": [
{ "id": "prime-ready", "name": "Prime Ready", "game": "CS2", "priceCents": 43, "listPriceCents": 45, "currency": "EUR", "available": 128, "inStock": true },
{ "id": "rust-1000-plus-hours", "name": "Rust 1000+ Hours", "game": "Rust", "priceCents": 185, "listPriceCents": 185, "currency": "EUR", "available": null, "inStock": false }
]
}id— the product id. It is what/api/v1/stock,/api/v1/orderand/api/v1/orderscall a product; the buy endpoints take a shorttypeinstead (see Buy accounts).available— how many we will sell right now, ornullwhile stock is unknown.inStockis the yes/no.- Text format (the default): one product per line, tab separated:
id, price in euros, available,in_stockorout_of_stock. - A key we do not recognise is ignored rather than refused, so you get list prices. If your prices look like list prices, check the key with
/api/v1/me. - Rate limit: 60 requests a minute per IP address.
Authentication
Create a key in your account. It spends that account's balance.
Stock needs no key. Products works without one (with a key it shows your own prices). Everything else needs one.
Getting a key
- Sign in and open your account page.
- Press Create API key. The key is shown once and never again, because we store only its hash.
- Up to five active keys per account. Revoke any of them at any time and it stops working immediately.
- A key is full (can buy, replace and manage webhooks) or read-only (can look things up). Make keys for monitors and teammates read-only.
Sending it
X-Api-Key: rsk_...header (recommended)Authorization: Bearer rsk_...header?key=rsk_...on the query string — works, but avoid it
Prefer a header. A key on the query string is part of the URL, so it gets written into access logs, proxy logs and browser history wherever the request passes. A header does not. If you have been sending ?key=, switch to X-Api-Key and roll your key from your account page.
What it can spend
A key spends the balance of the account that created it, the same balance you see on your account page. Every key on the account shares that balance and the same buy rate limit. There is no separate credit and no overdraft: if your balance will not cover an order we refuse it with E1401 and charge nothing.
curl -H "X-Api-Key: rsk_..." "https://www.nfa.pub/api/v1/balance"
0.90To check which key you are using and what it may do, call /api/v1/me.
Response format
Plain text by default on the original endpoints, JSON on request. The newer endpoints are JSON.
On /products, /stock, /balance, /order, /replace and the buy endpoints, add result=json for a JSON body, or result=text to be explicit. Anything else is treated as text. Errors follow the same switch.
/limits, /me, /orders, /ledger and /webhooks always answer JSON, errors included; /orders and /ledger also do CSV with format=csv.
Text
curl -H "X-Api-Key: rsk_..." "https://www.nfa.pub/api/v1/cs2?type=prime"
76561198168801886----eyAidHlwIjogIkpXVCIs...
curl "https://www.nfa.pub/api/v1/stock?product=xg-dayz"
xg-dayz 75 in_stockJSON
{
"ok": true,
"order_id": "4b53000e7b4677d2-c69a8e94132d75e8",
"accounts": ["76561198168801886----eyAid..."],
"count": 1,
"charged_eur": 0.3,
"cached": false
}Money and time
Fields ending in Cents are whole euro cents; fields ending in _eur are euros. Timestamps on the newer endpoints are ISO 8601 in UTC; /order returns unix milliseconds; webhook created is unix seconds.
Errors
Text errors are the bare code, so the body is exactly E1401. JSON errors carry ok, code and an error sentence. Either way the code is also in the X-Error-Code header, so you can branch without parsing the body.
Rate limits
How many requests a minute each endpoint takes, and what is counted.
Over a limit you get HTTP 429 with E1002 and nothing happens: no charge, no order. Wait for the minute to roll over and retry. /api/v1/me returns this same table.
/api/v1/cs2,/api/v1/rust,/api/v1/extra: 120 requests a minute per IP address. Counted before the key is looked up, so guessing keys is throttled too./api/v1/cs2,/api/v1/rust,/api/v1/extra: 30 requests a minute per account (all keys together). Shared by every key on the account, across all three buy endpoints./api/v1/products: 60 requests a minute per IP address./api/v1/stock: 60 requests a minute per IP address./api/v1/balance: 120 requests a minute per IP address./api/v1/limits: 120 requests a minute per IP address./api/v1/order: 120 requests a minute per IP address./api/v1/replace: 60 requests a minute per IP address./api/v1/replace: 20 requests a minute per key./api/v1/me: 120 requests a minute per IP address./api/v1/orders: 60 requests a minute per IP address./api/v1/ledger: 60 requests a minute per IP address./api/v1/orders: 30 requests a minute per account (all keys together)./api/v1/ledger: 30 requests a minute per account (all keys together)./api/v1/webhooks,/api/v1/webhooks/{id},/api/v1/webhooks/{id}/rotate,/api/v1/webhooks/{id}/enable,/api/v1/webhooks/{id}/test,/api/v1/webhooks/{id}/deliveries: 120 requests a minute per IP address./api/v1/webhooks,/api/v1/webhooks/{id},/api/v1/webhooks/{id}/rotate,/api/v1/webhooks/{id}/enable,/api/v1/webhooks/{id}/test: 20 requests a minute per account (all keys together). Changes only: creating, deleting, rotating, enabling and testing endpoints./api/v1/webhooks/{id}/test: 6 requests a minute per account (all keys together). On top of the limit on changes.
Buys are counted per account, not per key: five keys on one account still share one allowance. E1707 is different: that is the purchases-per-hour limit you set yourself under API protection.
Buy accounts
GET / POST /api/v1/cs2Charge your balance and get account lines back immediately.
Three endpoints share one shape: /api/v1/cs2, /api/v1/rust and /api/v1/extra. Pass a type and optionally a quantity as query parameters. GET and POST both work; POST reads the same query parameters, not a body.
Send your key as an X-Api-Key header (or Authorization: Bearer; ?key= still works but writes the key into logs). Responses are plain text by default, one account per line. Add result=json for JSON.
Parameters
typerequired | CS2: prime, premier, premier-4-medals, premier-10-medals, premier-inactive, premier-10k, premier-15k, premier-20k, premier-rare. Rust: 1-99, 100-199, 200-499, 500-999, 1000-plus, inactive, premium-1-99, premium-100-199, premium-200-499, premium-500-999, premium-1000-plus, premium-inactive (the premium- types carry a Rust skin inventory worth $15+). Extra: r6, dayz, bf6, wardogs-0-100, wardogs-100-201, wardogs-201-301, arc-0-100, arc-100-200, arc-200-plus, apex-0-100, apex-100-200, apex-200-plus, r6-100-201, r6-201-301, r6-ranked, dayz-100-plus, bf6-100-plus, arc-50-100, tarkov-0-100, tarkov-100-200, tarkov-200-plus, ark-0-100, ark-100-plus, ark-inactive. |
quantity | Whole number from 1 to 100. Defaults to 1. |
result | json for a JSON body. Anything else returns plain text. |
Idempotency-Key | Header, up to 200 characters. Repeating a call with the same key and parameters returns the original result (cached: true) instead of charging again. |
What you get
{
"ok": true,
"order_id": "4b53000e7b4677d2-c69a8e94132d75e8",
"accounts": ["76561198168801886----eyAid..."],
"count": 1,
"charged_eur": 0.3,
"cached": false
}Keep order_id: it is how /api/v1/order, /api/v1/replace, /api/v1/orders and webhooks refer to this order.
How charging works
- You are charged the
priceCentsthat/api/v1/productsshows you, per account delivered. - Your balance is checked before anything is reserved, so a shortfall costs nothing.
- You are charged only for accounts actually delivered. A partial fill is refunded down to what shipped.
- If nothing can be delivered the whole charge is reversed and you get an error, never a silent loss.
- Send an
Idempotency-Keyheader on every buy, so a retry after a timeout can never charge you twice. - Rate limited to 30 buys a minute per account: every key on the account shares the same 30, across all three buy endpoints. Separately, 120 requests a minute per IP address.
Stock
GET / POST /api/v1/stockLive stock for every product. No key needed.
Returns every product id with how many we will sell right now and a stock state. Prices are in /api/v1/products.
curl "https://www.nfa.pub/api/v1/stock?result=json"
curl "https://www.nfa.pub/api/v1/stock?product=prime-ready&result=json"{
"ok": true,
"count": 2,
"stock": [
{ "id": "prime-ready", "available": 128, "inStock": true, "state": "in" },
{ "id": "rust-1000-plus-hours", "available": null, "inStock": false, "state": "unknown" }
]
}product | One product id. An id we do not sell is refused with E1104. |
result | json for JSON. Text is one product per line: id, available, in_stock or out_of_stock, tab separated. |
state—in,low(10 or fewer),critical(3 or fewer),out, orunknownwhen our supplier has not answered recently.availableisnullwhile it is unknown.- Rate limit: 60 requests a minute per IP address.
Account protection
GET / POST /api/v1/limitsLimits and an IP allowlist you set for your own keys, and an endpoint to read them.
Nothing here is on until you turn it on. In your account, under API protection, you can set any of these for every key on your account:
- IP allowlist. Only these IP addresses or ranges (CIDR, IPv4 or IPv6) can use your keys. Everything else gets
E1702. - Daily spending limit. The most the API may spend per day, reset at 00:00 UTC. An order that would cross it gets
E1708. - Per-order limit and max accounts per request (
E1706,E1705). - Purchases per hour (
E1707). - Allowed products. Only the products you tick can be bought through the API (
E1704). - Pause. One switch that stops every API purchase straight away (
E1703). - Read-only keys. A key that can check balance, stock, products, orders, the ledger and webhooks but never sees logins and cannot buy, replace or change webhooks (
E1701). Good for a monitor or a teammate. - Alerts. A Discord DM when a request is blocked, so a leaked key shows up the first time someone tries it.
Read your current limits and what is left today, so your own code can stop before it is refused (/api/v1/me has the settings too, without today's usage):
curl -H "X-Api-Key: rsk_..." "https://www.nfa.pub/api/v1/limits"{
"ok": true,
"paused": false,
"key_scope": "full",
"daily_limit_eur": 50,
"spent_today_eur": 12.3,
"remaining_today_eur": 37.7,
"resets_at": "2026-10-26T00:00:00.000Z",
"per_order_limit_eur": 10,
"max_quantity": 20,
"hourly_limit": null,
"purchases_this_hour": 4,
"ip_allowlist": true,
"allowed_products": null
}Any limit you have not set comes back as null. Refused requests are never charged. Always JSON.
Your account
GET /api/v1/meBalance, this key, your protection settings, rate limits and volume discount, in one call.
A key is required; read-only keys are fine. Always JSON. Handy as a health check for an integration: if this answers, the key works and you can see what it is allowed to do.
curl -H "X-Api-Key: rsk_..." "https://www.nfa.pub/api/v1/me"{
"ok": true,
"balance": { "cents": 12550, "eur": 125.5, "currency": "EUR" },
"key": { "label": "shop server", "hint": "rsk_AbCd…wXyZ", "scope": "full", "createdAt": "2026-09-14T10:02:11.000Z" },
"protection": {
"paused": false,
"ipAllowlistCount": 2,
"dailyLimitCents": 50000,
"perOrderLimitCents": null,
"maxQuantity": 20,
"hourlyLimit": null,
"productRestriction": false
},
"rateLimits": [
{ "name": "buy", "endpoints": ["/api/v1/cs2", "/api/v1/rust", "/api/v1/extra"], "limit": 30, "windowSeconds": 60, "per": "account" },
…
],
"volumeDiscount": {
"enrolled": true,
"active": true,
"discountPct": 5,
"band": { "minUnits": 200, "discountPct": 5 },
"unitsCounted": 238,
"windowDays": 30,
"nextBand": { "minUnits": 500, "discountPct": 8, "unitsToGo": 262 },
"bands": [ { "minUnits": 0, "discountPct": 0 }, { "minUnits": 50, "discountPct": 3 }, … ]
}
}Fields
key.scope—fullcan buy and replace;readcan only look things up.protection— what you set under API protection in your account. Limits you have not set arenull. For what is left of today's budget, use/api/v1/limits.rateLimits— every limit that applies, and whether it counts per IP, per key or per account.volumeDiscount— only when your account is on the volume programme; otherwise just{"enrolled": false, "active": false, "discountPct": 0}.unitsCountedis accounts delivered to you in the lastwindowDays;bandis the band that volume reaches andnextBandthe one after it.discountPctis the discount applied right now.- The discount is a percentage off list price. The exact price per product, after any discount, is
priceCentsin/api/v1/products: some products are discounted less or not at all, and a price agreed for your account is never discounted further.
Rate limit: 120 requests a minute per IP address.
Balance
GET / POST /api/v1/balanceThe balance your keys spend from.
curl -H "X-Api-Key: rsk_..." "https://www.nfa.pub/api/v1/balance"
12.50
curl -H "X-Api-Key: rsk_..." "https://www.nfa.pub/api/v1/balance?result=json"{
"ok": true,
"balance_eur": 12.5,
"balance_cents": 1250
}Every movement behind that number is in /api/v1/ledger. Rate limit: 120 requests a minute per IP address.
Replace an account
POST /api/v1/replaceSwap a faulty account on one of your own orders, inside the warranty window. Self-serve, no ticket.
Send the order and the steam id of the account that is not working and you get a replacement back in the same response. This is self-serve: every full key can do it, there is nothing to request or set up, and no one on our side has to approve it. Read-only keys get E1701. In short: three replacements per order, six hours from delivery, and only a real swap uses one up.
Request
curl -X POST "https://www.nfa.pub/api/v1/replace" \
-H "X-Api-Key: rsk_..." \
-H "Content-Type: application/json" \
-d '{"order":"INVOICE_OR_TOKEN","account":"76561199…","reason":"cannot log in"}'Fields
- order — the invoice reference or order token from the purchase (
order_idandinvoicealso work). - account — the steam id of the account you want replaced (
steam_idalso works). - reason — optional, free text, kept with the request.
- result=json — optional, returns json instead of the plain account line.
- The same fields work as query parameters, and GET is accepted too. Prefer POST with a JSON body.
Response
{
"ok": true,
"account": "76561199…----…",
"replaced": "76561199…",
"used": 1,
"remaining": 2,
"warranty_ends_at": "2026-10-25T20:31:04.472Z",
"paused_ms": 0,
"reason": "login_failed"
}Without result=json the body is the replacement account line on its own, exactly like a purchase.
Either way the response carries an X-Replace-Reason header with what the checker found wrong, so you can see why it was swapped without parsing the body.
The rules
- Six hours from delivery. The window starts when the order was delivered, not when it was paid.
- Asking in time is what counts. If a replacement is requested inside the window we owe it, even if we are out of stock at that moment and only fulfil it later.
- Three replacements per order. That is the budget for the whole order, however many accounts are on it.
- A replacement can be replaced. The account we send you is a new one, so if it also turns out faulty you can swap it again — that simply uses another of the three.
- The same account is only swapped once. Once we have replaced an account, that particular steam id is finished with.
- Only a real swap costs you one. If our check finds the account still works, or we are out of stock, nothing is deducted.
- Only your own orders. An order billed to another account is not visible to your key.
- The account has to actually be faulty. We check it with the supplier first and refuse if it still works.
What we do not replace
Three states are not faults and are never swapped. Asking for one of them ends the warranty on that account for good. Only that account: everything else on the order keeps its cover.
- CS2 cooldowns. A cooldown is the account working normally and it runs out on its own. Wait it out and the account is fine. Refused with E1616.
- VAC bans. Not covered at any point. Refused with E1618.
- Accounts banned after we delivered them. Same answer as a VAC ban, refused with E1618.
- Once an account has been refused for one of these, every later call on that steam id comes back E1617, including from the website.
An example
An order with one account. It stops working, you call replace, we send account B. One of three used.
B stops working too. You call replace with B's steam id and we send C. Two of three used.
You report C, but our check finds it working. You get E1610 back and nothing is deducted. Still two of three used.
Every successful response carries remaining, which is how many replacements that order has left.
When it says no
- E1101 you did not send both
orderandaccount. - E1602 no order with that reference.
- E1603 that order is not yours.
- E1604 that order has not been delivered yet.
- E1605 the window has closed, counting only time the product was actually in stock.
- E1606 that steam id is not on that order.
- E1607 that account was already replaced once.
- E1608 that order has used all three replacements.
- E1609 no replacement stock for that product right now.
- E1610 that account still checks out fine, so it was not replaced.
- E1611 the checker could not reach that account, try again shortly.
- E1612 replacements are blocked for this customer.
- E1614 held for manual review, so a person has to look at it first.
- E1615 replacements are paused right now.
- E1616 that account is on a CS2 cooldown, which is not a fault we replace.
- E1617 the warranty on that account was voided by an earlier cooldown or ban claim.
- E1618 that account is banned, which is not covered.
- E1619 that order was refunded in full.
- E1620 a chargeback is open on that order, so replacements are paused.
- E1621 another replacement on that order is still running, retry in a few seconds.
- E1701 this key is read-only. E1702 this IP is not on your allowlist.
Rate limits: 60 requests a minute per IP address, and 20 requests a minute per key.
Check an order
GET / POST /api/v1/orderLook up the state of one of your own orders, and fetch its accounts again.
Ask what happened to an order you placed. Useful when a call timed out, a script crashed, or you bought a pre-order and want to know whether stock has landed. The accounts come back exactly as they were delivered, so this doubles as a way to recover a response you lost.
A key is required, and an order is only visible to the key whose account bought it. Someone else's order id is worth nothing. Read-only keys can use this too, but they only get the order's state: the accounts and steam_ids lists stay empty, because logins go to full keys only. To list many orders at once, use /api/v1/orders; to be told when one changes, use webhooks.
Request
curl "https://www.nfa.pub/api/v1/order?id=INVOICE_OR_TOKEN&result=json" \
-H "X-Api-Key: rsk_..."Fields
- id — the
order_idfrom the buy response (the invoice reference) or the order token. Both work.order_idis accepted as an alias. - result —
jsonfor the full object,textfor the account lines only (or the state word when there are none).
Response
{
"ok": true,
"order_id": "339620d0e1843b5b-cc957e7efa86a0db",
"state": "delivered",
"status": "delivered",
"product_id": "rust-500-999-hours",
"quantity": 1,
"delivered": 1,
"accounts": ["76561198987883144----eyAidHlwIjo…"],
"steam_ids": ["76561198987883144"],
"charged_eur": 1.45,
"refunded_eur": 0,
"preorder": false,
"created_at": 1791635461489,
"delivered_at": 1791635464472,
"warranty_ends_at": 1791657064472,
"error": null
}The three timestamps here are unix milliseconds.
State
Branch on state, not on status. It is the one field we keep stable for you, and the same vocabulary as /api/v1/orders and the webhook payloads.
- awaiting_payment — created, not paid yet.
- preorder — paid, waiting for stock. Nothing is owed from you.
- processing — paid, we are sourcing it right now.
- partial — some units delivered, the rest refunded.
- delivered — everything shipped.
accountsis filled and the warranty clock is running. - refunded — nothing delivered, money returned.
- cancelled — the order was cancelled.
Errors
- E1001 — no key, or a revoked one.
- E1101 — you did not send
id. - E1602 — no order with that id.
- E1603 — that order was not bought by this key's account.
Rate limit: 120 requests a minute per IP address. Poll a pre-order every few seconds at most — the state does not change faster than stock arrives. Better: subscribe to the preorder.delivered webhook.
List your orders
GET /api/v1/ordersEvery order your account bought, newest first, with filters, paging and CSV export.
The same orders /api/v1/order will show you: everything bought with your account's email address, through the API or the site. Always JSON, or CSV with format=csv.
curl -H "X-Api-Key: rsk_..." \
"https://www.nfa.pub/api/v1/orders?state=partial,refunded&created_after=2026-10-01&limit=100"{
"ok": true,
"orders": [
{
"order": "339620d0e1843b5b-cc957e7efa86a0db",
"product": "rust-500-999-hours",
"quantity": 2,
"delivered": 1,
"state": "partial",
"amountCents": 290,
"refundedCents": 145,
"currency": "EUR",
"createdAt": "2026-10-25T14:31:01.489Z"
}
],
"nextCursor": "Q2hhbmdlTWU…"
}Parameters
limit | Page size, 1 to 100. Default 50. |
cursor | The nextCursor from the previous page, passed back unchanged. Opaque: do not build or decode it. |
created_after | Inclusive. Unix seconds or ISO 8601 (2026-10-01, 2026-10-01T12:00:00Z). No offset means UTC. |
created_before | Exclusive, same formats. created_after=2026-10-01&created_before=2026-11-01 is exactly October. |
format | csv for a CSV download. The next page's cursor is then in the X-Next-Cursor header. |
state | Only these states, comma separated: awaiting_payment, preorder, processing, partial, delivered, refunded, cancelled. |
product | Only these product ids, comma separated (up to 20). |
include | accounts adds an accounts array of delivered login lines to each order. Full keys only; not available with format=csv. |
Fields
order— the id/api/v1/orderand/api/v1/replaceaccept: theorder_idyour buy returned.amountCents— what the order was invoiced at.refundedCents— what has gone back to your balance for it. What you paid net is the difference.createdAt— ISO 8601, UTC.
Paging
- Newest first. Keep calling with
cursorset to the lastnextCursoruntil it comes backnull. - Pages never shift: new rows arriving while you page through do not push rows onto the next page or show one twice.
- Anything malformed (a
limitof 0 or 500, an unreadable date, a cursor from another endpoint or account) is refused withE1101rather than guessed at.
Credentials
Logins are left out unless you ask for them with include=accounts, and only a full key may ask: a read-only key gets E1701, so a monitoring key cannot pull every account you ever bought. They are never in CSV (E1101). For one order, /api/v1/order works with any key.
Rate limit: 60 requests a minute per IP address.
Balance history
GET /api/v1/ledgerEvery movement on your balance, newest first, for reconciling against your own books.
Purchases, refunds, top-ups and adjustments, one entry each. The entries add up to your balance. Same paging, date filters and CSV export as /api/v1/orders.
curl -H "X-Api-Key: rsk_..." "https://www.nfa.pub/api/v1/ledger?created_after=2026-10-01&format=csv" -o ledger.csv{
"ok": true,
"entries": [
{ "id": "led_3f9c2a7e5b1d48c0a6e2f913", "at": "2026-10-25T14:31:05.112Z", "kind": "refund", "amountCents": 145, "currency": "EUR", "order": "339620d0e1843b5b-cc957e7efa86a0db" },
{ "id": "led_81e0b4c95d2a7f3e1c6b0a44", "at": "2026-10-25T14:31:01.512Z", "kind": "purchase", "amountCents": -290, "currency": "EUR", "order": "339620d0e1843b5b-cc957e7efa86a0db" },
{ "id": "led_c2d7a0e9f4b3816d5e2a9c07", "at": "2026-10-24T09:12:44.000Z", "kind": "topup", "amountCents": 5000, "currency": "EUR", "order": "9e1b7c40aa52d3f8-0c6e1b2d9f7a4e35" }
],
"nextCursor": null
}Parameters
limit | Page size, 1 to 100. Default 50. |
cursor | The nextCursor from the previous page, passed back unchanged. Opaque: do not build or decode it. |
created_after | Inclusive. Unix seconds or ISO 8601 (2026-10-01, 2026-10-01T12:00:00Z). No offset means UTC. |
created_before | Exclusive, same formats. created_after=2026-10-01&created_before=2026-11-01 is exactly October. |
format | csv for a CSV download. The next page's cursor is then in the X-Next-Cursor header. |
Fields
id— opaque and stable; use it to de-duplicate when you import.kind—purchase(an API, dashboard or site purchase paid from balance),refund(money for an order coming back),topup,adjustment(a correction by us, a carried-over or transferred balance), orother.amountCents— signed: positive adds to your balance, negative takes from it.order— the order the entry belongs to, when there is one, as the id/api/v1/orderaccepts. Otherwisenull.
Paging
- Newest first. Keep calling with
cursorset to the lastnextCursoruntil it comes backnull. - Pages never shift: new rows arriving while you page through do not push rows onto the next page or show one twice.
- Anything malformed (a
limitof 0 or 500, an unreadable date, a cursor from another endpoint or account) is refused withE1101rather than guessed at.
Rate limit: 60 requests a minute per IP address.
Webhooks
GET / POST / DELETE /api/v1/webhooksGet a signed HTTPS call when one of your orders changes, instead of polling.
Register an HTTPS endpoint and we POST a small signed JSON event to it when one of your orders is delivered, partly delivered, fails, is refunded or gets more accounts, or when a replacement or a pre-order lands. The event never contains credentials: fetch those with GET /api/v1/order?id= and the order id in the event.
Which orders
- Only the account that bought the order hears about it, by the same rule as
/api/v1/order: the order's buyer email is your account's email. - Only while the account has at least one active API key. Revoke every key and webhook deliveries stop too.
- Balance top-ups are never announced.
- An endpoint gets events for changes from the time it exists; nothing earlier is replayed.
Managing endpoints
All of these answer JSON, errors included. Reading (the list and the delivery log) works with any key, read-only included. Every change needs a full key; a read-only key gets E1701. An id that does not exist, or belongs to another account, gets E1803.
GET /api/v1/webhooks—{"ok": true, "webhooks": [endpoint, …]}. Secrets are never shown here.POST /api/v1/webhookswith a JSON body{"url": "https://…", "events": ["order.delivered", …]}— add an endpoint. Leave outeventsto get every event exceptwebhook.test; an empty list or an unknown type is refused withE1805. Answers{"ok": true, "webhook": endpoint, "secret": "whsec_…"}. The secret is shown this once: store it straight away. At most 3 endpoints per account (E1802).DELETE /api/v1/webhooks/{id}—{"ok": true}. Anything still queued for it is dropped.POST /api/v1/webhooks/{id}/rotate—{"ok": true, "secret": "whsec_…"}, shown once. The old secret stops working at once: every delivery from now on, retries of earlier events included, is signed with the new one.POST /api/v1/webhooks/{id}/enable—{"ok": true, "webhook": endpoint}withstatusback toactiveand the failure count reset. Deliveries held while it was disabled go out on the next run.POST /api/v1/webhooks/{id}/test—{"ok": true, "queued": true, "eventId": "evt_…"}. Queues onewebhook.testevent for that endpoint, whatever it is subscribed to, signed and retried like any other.GET /api/v1/webhooks/{id}/deliveries?limit=20—{"ok": true, "deliveries": [ … ]}, newest first.limitis 1 to 50, default 20; values outside that are clamped. Each delivery:eventId,type,status,attempts,lastStatus(the HTTP status your endpoint answered last,nullif it never answered),createdAt,deliveredAt. Only status codes are kept, never a body. Finished deliveries are kept for 30 days.
curl -X POST "https://www.nfa.pub/api/v1/webhooks" \
-H "X-Api-Key: rsk_..." \
-H "Content-Type: application/json" \
-d '{"url":"https://hooks.example.com/nfa","events":["order.delivered","order.partial","order.refunded"]}'An endpoint
{
"id": "wh_Q2xZa1b2C3d4E5f6",
"url": "https://hooks.example.com/nfa",
"events": ["order.delivered", "order.partial", "order.refunded"],
"status": "active",
"createdAt": "2026-10-26T12:00:00.000Z",
"lastSuccessAt": "2026-10-26T12:05:01.000Z",
"lastFailureAt": null,
"lastStatus": 200,
"disabledReason": null
}status—active, ordisabledafter 50 failed attempts in a row;disabledReasonthen says so.lastSuccessAt,lastFailureAt,lastStatus— the last attempt each way, and the HTTP status of the most recent one.- Every timestamp in these objects is ISO 8601, UTC.
Delivery statuses
pending— waiting for its first attempt or its next retry (and held while the endpoint is disabled).delivered— your endpoint answered 2xx.failed— all 8 attempts failed.expired— still not delivered 3 days after it was queued, for example because the endpoint was disabled.
URLs we accept
https://on the standard port (443), at most 500 characters, with no username or password and no spaces, control characters or backslashes. A#fragmentis dropped.- A public hostname: not an IP address in any notation, not
localhostor a private or reserved name (.local,.internal,.lan,.home.arpa,.test,.example,.invalid,.onionand the like), and not one of our own domains. - When you register it, the name must resolve, and only to public addresses.
- Anything else is refused with
E1801, and theerrorsentence says which rule it broke.
Events
order.delivered— every account on the order has been delivered.order.partial— some accounts were delivered and the rest refunded to your balance, including a pre-order that was only partly filled.order.failed— a delivery attempt failed. It may still be retried or held for review; money going back is its ownorder.refundedevent. A retry that fails again with nothing changed is not announced twice.order.refunded— money for the order went back, in full or in part. One event each time the total returned changes.order.updated— more accounts were added to an order after it was first delivered.replacement.completed— a replacement was delivered or recorded, whether through/api/v1/replace, the site, our Discord bot or by staff.preorder.delivered— a pre-order's stock arrived and it was delivered.webhook.test— sent by the test endpoint only.
Payload
{
"id": "evt_Zk3q9V0c1mR8xT2aLp7wQe",
"type": "order.partial",
"created": 1793023861,
"data": {
"order": "339620d0e1843b5b-cc957e7efa86a0db",
"product": "rust-500-999-hours",
"quantity": 2,
"delivered": 1,
"state": "partial",
"refundedCents": 145,
"currency": "EUR"
}
}id— unique per event and the same on every retry. De-duplicate on it.created— unix seconds when the change happened (not when this attempt was sent).order— the id/api/v1/orderaccepts.stateuses the same words as/api/v1/order.refundedCents— the total returned for this order so far, by any route, ornullwhen nothing has been returned.webhook.testcarries the same fields, allnullexceptcurrency.
Headers
Content-Type: application/jsonandUser-Agent: nfa.pub-webhooks/1.0.Nfa-Event-Id— the event id, the same asidin the body.Nfa-Event-Type— the event type, the same astypein the body.Nfa-Timestamp— unix seconds when this attempt was signed; the same value astin the signature.Nfa-Signature: t=<unix seconds>,v1=<hex>—v1is the hex HMAC-SHA256 oft + "." + rawBody, keyed with your endpoint's secret.
Verifying the signature
- Use the raw request body, byte for byte. Parsing and re-serialising the JSON changes it and the signature will not match. Every retry sends the same body, re-signed with a fresh
t. - The HMAC key is the whole secret exactly as you received it,
whsec_prefix included, as UTF-8 bytes. - Compare in constant time (
timingSafeEqual,hmac.compare_digest), never with==. - Reject a
tmore than 5 minutes from your clock, so a captured request cannot be replayed later.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
// rawBody: the request body exactly as received (a Buffer or string), never
// JSON.stringify(req.body) -- re-serialising changes the bytes.
// secret: the whole "whsec_..." string you were given, prefix included.
export function verifyNfaWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) {
let t = null;
const signatures = [];
for (const piece of String(signatureHeader || "").split(",")) {
const at = piece.indexOf("=");
if (at < 1) continue;
const name = piece.slice(0, at).trim();
const value = piece.slice(at + 1).trim();
if (name === "t" && /^\d{1,12}$/.test(value)) t = Number(value);
if (name === "v1" && /^[0-9a-f]{64}$/.test(value)) signatures.push(value);
}
if (t === null || !signatures.length) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false; // stale (or clock skew)
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
return signatures.some((hex) => {
const given = Buffer.from(hex, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
// Express: take the raw body for this route only.
const app = express();
app.post("/nfa-webhook", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyNfaWebhook(req.body, req.get("Nfa-Signature"), process.env.NFA_WEBHOOK_SECRET)) {
return res.status(400).end();
}
const event = JSON.parse(req.body.toString("utf8"));
if (alreadySeen(event.id)) return res.status(200).end(); // retries reuse the id
queueForProcessing(event); // answer fast, work later
res.status(200).end();
});Python
import hashlib, hmac, os, re, time
from flask import Flask, request
# raw_body: the request body exactly as received. secret: the whole "whsec_..."
# string you were given, prefix included.
def verify_nfa_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance: int = 300) -> bool:
t, signatures = None, []
for piece in (signature_header or "").split(","):
name, sep, value = piece.partition("=")
name, value = name.strip(), value.strip()
if not sep:
continue
if name == "t" and re.fullmatch(r"[0-9]{1,12}", value):
t = int(value)
elif name == "v1" and re.fullmatch(r"[0-9a-f]{64}", value):
signatures.append(value)
if t is None or not signatures:
return False
if abs(int(time.time()) - t) > tolerance: # stale (or clock skew)
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, sig) for sig in signatures)
# Flask: request.get_data() is the raw body.
app = Flask(__name__)
@app.post("/nfa-webhook")
def nfa_webhook():
if not verify_nfa_webhook(request.get_data(), request.headers.get("Nfa-Signature", ""), os.environ["NFA_WEBHOOK_SECRET"]):
return "", 400
event = request.get_json()
if already_seen(event["id"]): # retries reuse the id
return "", 200
queue_for_processing(event)
return "", 200No fixed IPs
Webhooks are sent from Cloudflare's network, not from a fixed IP address. The source address changes and is shared with other Cloudflare traffic, so do not IP-allowlist webhook requests: an allowlist will drop real deliveries and proves nothing. Verify the signature instead; it is the only proof a request came from us.
Delivery and retries
- We check for new events and due retries about once a minute.
- Answer with any 2xx within 10 seconds. Anything else, no answer, or a redirect counts as a failure: redirects are never followed, so point the URL at the final address.
- Up to 8 attempts: the first, then retries 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours after each failure. After the 8th the delivery is
failed; one still pending 3 days after it was queued isexpired. - After 50 failed attempts in a row the endpoint is disabled and holds its deliveries. Fix it, then call
POST /api/v1/webhooks/{id}/enable. - Delivery is at least once: the same event can arrive more than once, and events can arrive out of order. De-duplicate on the event id, and read the current state from
/api/v1/orderwhen order matters. - Answer first and do the work afterwards, so a slow job never turns into a timeout and a retry.
- Rate limits on the management calls: 120 requests a minute per IP address across all of them, 20 changes a minute per account, and 6 test events a minute per account.
Error codes
On failure the JSON body carries a stable code, also sent as X-Error-Code. Branch on the code. The error field is a generic message that is safe to show to a customer as-is.
E1000 | 500 | Something went wrong on our side. Retry; if it persists, contact support. |
E1001 | 401 | Your key is missing, malformed, revoked, or unknown. |
E1002 | 429 | You hit a rate limit. Back off and retry shortly. See Rate limits for the numbers. |
E1003 | 413 | The request body exceeded the 4 KB size limit. |
E1101 | 400 | A required parameter was missing, or one was not valid (for example a bad `limit`, date or `cursor` on the list endpoints). |
E1102 | 400 | The `type` parameter is missing or not recognised for this endpoint. |
E1103 | 400 | Quantity must be a whole number between 1 and 100. |
E1104 | 400 | No product matches that id or type. |
E1201 | 409 | The shop is temporarily closed. Try again later. |
E1202 | 409 | The product is misconfigured on our side. Contact support. |
E1301 | 409 | You reused an Idempotency-Key with different parameters. |
E1302 | 409 | A previous call with the same Idempotency-Key is still running. Retry shortly. |
E1401 | 402 | Your balance cannot cover this purchase. Top up, then retry. Nothing was charged. |
E1501 | 409 | We could not source the accounts. Nothing was charged. |
E1502 | 503 | A delivery error occurred and your balance was refunded. Retry. |
E1503 | 503 | Our supplier is briefly unavailable. Nothing was charged. Retry shortly. |
E1601 | 403 | This key is not allowed to do that. |
E1602 | 404 | No order with that id. |
E1603 | 403 | That order was not bought with this key's account, so this key cannot see or act on it. |
E1604 | 409 | That order has not been delivered yet, so there is nothing to replace. |
E1605 | 409 | The replacement window on that order has closed. |
E1606 | 400 | That account is not on that order. |
E1607 | 409 | That account has already been replaced. |
E1608 | 409 | That order has used all of its replacements. |
E1609 | 409 | No replacement in stock right now. Nothing was used up; try again when it restocks. |
E1610 | 409 | Our check says that account still works, so it was not replaced and nothing was used up. |
E1611 | 503 | The account check was inconclusive. Try again in a few minutes. |
E1612 | 403 | Purchases or replacements are blocked for this customer. |
E1613 | 409 | That claim came too long after delivery. |
E1614 | 409 | Held for manual review, so a person has to look at it first. |
E1615 | 503 | Replacements are paused right now. |
E1616 | 409 | A CS2 cooldown is not a fault and lapses on its own, so it is not replaced. |
E1617 | 409 | The warranty on that account was voided by an earlier cooldown or VAC ban claim. Other accounts on the order are unaffected. |
E1618 | 409 | A VAC ban (or a ban after delivery) is not covered, so that account is not replaced. |
E1619 | 409 | That order was refunded in full, so it no longer carries a warranty. |
E1620 | 409 | A chargeback is open on that order, so replacements are paused until it is settled. |
E1621 | 409 | Another replacement on that order is still running. Retry in a few seconds. |
E1701 | 403 | This key is read-only: it can look things up but not buy, replace, fetch credentials in bulk with `include=accounts`, or change webhooks. |
E1702 | 403 | The request came from an IP that is not on your account's IP allowlist. |
E1703 | 403 | You paused API purchases on your account. |
E1704 | 403 | That product is not on your account's allowed products list. |
E1705 | 400 | More accounts in one request than your max per request. |
E1706 | 403 | The order costs more than your per-order spending limit. |
E1707 | 429 | You reached your purchases-per-hour limit. |
E1708 | 403 | The order would go over your daily spending limit. It resets at 00:00 UTC. |
E1801 | 400 | That webhook URL cannot be used. It must be `https://` on port 443, a public hostname (not an IP address, a private or reserved name, or one of our domains), at most 500 characters, and resolve only to public addresses. The `error` sentence says which rule it broke. |
E1802 | 409 | The account already has 3 webhook endpoints. Delete one first. |
E1803 | 404 | No webhook with that id on this account. An id that belongs to another account gets the same answer. |
E1804 | 503 | Webhooks are not available right now, so endpoints cannot be created or their secrets rotated. Nothing was changed; try again later. |
E1805 | 400 | The `events` list is not valid: it must be a non-empty list of known event types. The `error` sentence lists them. |