nfa.pub

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.

https://www.nfa.pub/api/v1OpenAPIllms.txt

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/products for the catalogue with your prices and availability, and GET /api/v1/stock for live stock, optionally filtered with ?product=.
  • Buy with /api/v1/cs2, /api/v1/rust or /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/v1

Stock 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 bad limit, date or cursor on the list endpoints).
  • E1102 (HTTP 400): The type parameter 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 with include=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 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 (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): The events list is not valid: it must be a non-empty list of known event types. The error sentence lists them.
GET / POST

Products

GET / POST /api/v1/products

List 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/order and /api/v1/orders call a product; the buy endpoints take a short type instead (see Buy accounts).
  • available — how many we will sell right now, or null while stock is unknown. inStock is the yes/no.
  • Text format (the default): one product per line, tab separated: id, price in euros, available, in_stock or out_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.90

To 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_stock

JSON

{
  "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.

GET / POST

Buy accounts

GET / POST /api/v1/cs2

Charge 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

typerequiredCS2: 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.
quantityWhole number from 1 to 100. Defaults to 1.
resultjson for a JSON body. Anything else returns plain text.
Idempotency-KeyHeader, 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 priceCents that /api/v1/products shows 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-Key header 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.
GET / POST

Stock

GET / POST /api/v1/stock

Live 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" }
  ]
}
productOne product id. An id we do not sell is refused with E1104.
resultjson 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, or unknown when our supplier has not answered recently. available is null while it is unknown.
  • Rate limit: 60 requests a minute per IP address.
GET / POST

Account protection

GET / POST /api/v1/limits

Limits 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.

GET

Your account

GET /api/v1/me

Balance, 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 — full can buy and replace; read can only look things up.
  • protection — what you set under API protection in your account. Limits you have not set are null. 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}. unitsCounted is accounts delivered to you in the last windowDays; band is the band that volume reaches and nextBand the one after it. discountPct is the discount applied right now.
  • The discount is a percentage off list price. The exact price per product, after any discount, is priceCents in /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.

GET / POST

Balance

GET / POST /api/v1/balance

The 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.

POST

Replace an account

POST /api/v1/replace

Swap 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_id and invoice also work).
  • account — the steam id of the account you want replaced (steam_id also 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 order and account.
  • 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.

GET / POST

Check an order

GET / POST /api/v1/order

Look 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_id from the buy response (the invoice reference) or the order token. Both work. order_id is accepted as an alias.
  • result — json for the full object, text for 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. accounts is 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.

GET

List your orders

GET /api/v1/orders

Every 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

limitPage size, 1 to 100. Default 50.
cursorThe nextCursor from the previous page, passed back unchanged. Opaque: do not build or decode it.
created_afterInclusive. Unix seconds or ISO 8601 (2026-10-01, 2026-10-01T12:00:00Z). No offset means UTC.
created_beforeExclusive, same formats. created_after=2026-10-01&created_before=2026-11-01 is exactly October.
formatcsv for a CSV download. The next page's cursor is then in the X-Next-Cursor header.
stateOnly these states, comma separated: awaiting_payment, preorder, processing, partial, delivered, refunded, cancelled.
productOnly these product ids, comma separated (up to 20).
includeaccounts 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/order and /api/v1/replace accept: the order_id your 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 cursor set to the last nextCursor until it comes back null.
  • 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 limit of 0 or 500, an unreadable date, a cursor from another endpoint or account) is refused with E1101 rather 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.

GET

Balance history

GET /api/v1/ledger

Every 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

limitPage size, 1 to 100. Default 50.
cursorThe nextCursor from the previous page, passed back unchanged. Opaque: do not build or decode it.
created_afterInclusive. Unix seconds or ISO 8601 (2026-10-01, 2026-10-01T12:00:00Z). No offset means UTC.
created_beforeExclusive, same formats. created_after=2026-10-01&created_before=2026-11-01 is exactly October.
formatcsv 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), or other.
  • 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/order accepts. Otherwise null.

Paging

  • Newest first. Keep calling with cursor set to the last nextCursor until it comes back null.
  • 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 limit of 0 or 500, an unreadable date, a cursor from another endpoint or account) is refused with E1101 rather than guessed at.

Rate limit: 60 requests a minute per IP address.

GET / POST / DELETE

Webhooks

GET / POST / DELETE /api/v1/webhooks

Get 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/webhooks with a JSON body {"url": "https://…", "events": ["order.delivered", …]} — add an endpoint. Leave out events to get every event except webhook.test; an empty list or an unknown type is refused with E1805. 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} with status back to active and 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 one webhook.test event 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. limit is 1 to 50, default 20; values outside that are clamped. Each delivery: eventId, type, status, attempts, lastStatus (the HTTP status your endpoint answered last, null if 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, or disabled after 50 failed attempts in a row; disabledReason then 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 #fragment is dropped.
  • A public hostname: not an IP address in any notation, not localhost or a private or reserved name (.local, .internal, .lan, .home.arpa, .test, .example, .invalid, .onion and 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 the error sentence 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 own order.refunded event. 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/order accepts. state uses the same words as /api/v1/order.
  • refundedCents — the total returned for this order so far, by any route, or null when nothing has been returned.
  • webhook.test carries the same fields, all null except currency.

Headers

  • Content-Type: application/json and User-Agent: nfa.pub-webhooks/1.0.
  • Nfa-Event-Id — the event id, the same as id in the body.
  • Nfa-Event-Type — the event type, the same as type in the body.
  • Nfa-Timestamp — unix seconds when this attempt was signed; the same value as t in the signature.
  • Nfa-Signature: t=<unix seconds>,v1=<hex> — v1 is the hex HMAC-SHA256 of t + "." + 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 t more 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 "", 200

No 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 is expired.
  • 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/order when 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.

E1000500Something went wrong on our side. Retry; if it persists, contact support.
E1001401Your key is missing, malformed, revoked, or unknown.
E1002429You hit a rate limit. Back off and retry shortly. See Rate limits for the numbers.
E1003413The request body exceeded the 4 KB size limit.
E1101400A required parameter was missing, or one was not valid (for example a bad `limit`, date or `cursor` on the list endpoints).
E1102400The `type` parameter is missing or not recognised for this endpoint.
E1103400Quantity must be a whole number between 1 and 100.
E1104400No product matches that id or type.
E1201409The shop is temporarily closed. Try again later.
E1202409The product is misconfigured on our side. Contact support.
E1301409You reused an Idempotency-Key with different parameters.
E1302409A previous call with the same Idempotency-Key is still running. Retry shortly.
E1401402Your balance cannot cover this purchase. Top up, then retry. Nothing was charged.
E1501409We could not source the accounts. Nothing was charged.
E1502503A delivery error occurred and your balance was refunded. Retry.
E1503503Our supplier is briefly unavailable. Nothing was charged. Retry shortly.
E1601403This key is not allowed to do that.
E1602404No order with that id.
E1603403That order was not bought with this key's account, so this key cannot see or act on it.
E1604409That order has not been delivered yet, so there is nothing to replace.
E1605409The replacement window on that order has closed.
E1606400That account is not on that order.
E1607409That account has already been replaced.
E1608409That order has used all of its replacements.
E1609409No replacement in stock right now. Nothing was used up; try again when it restocks.
E1610409Our check says that account still works, so it was not replaced and nothing was used up.
E1611503The account check was inconclusive. Try again in a few minutes.
E1612403Purchases or replacements are blocked for this customer.
E1613409That claim came too long after delivery.
E1614409Held for manual review, so a person has to look at it first.
E1615503Replacements are paused right now.
E1616409A CS2 cooldown is not a fault and lapses on its own, so it is not replaced.
E1617409The warranty on that account was voided by an earlier cooldown or VAC ban claim. Other accounts on the order are unaffected.
E1618409A VAC ban (or a ban after delivery) is not covered, so that account is not replaced.
E1619409That order was refunded in full, so it no longer carries a warranty.
E1620409A chargeback is open on that order, so replacements are paused until it is settled.
E1621409Another replacement on that order is still running. Retry in a few seconds.
E1701403This key is read-only: it can look things up but not buy, replace, fetch credentials in bulk with `include=accounts`, or change webhooks.
E1702403The request came from an IP that is not on your account's IP allowlist.
E1703403You paused API purchases on your account.
E1704403That product is not on your account's allowed products list.
E1705400More accounts in one request than your max per request.
E1706403The order costs more than your per-order spending limit.
E1707429You reached your purchases-per-hour limit.
E1708403The order would go over your daily spending limit. It resets at 00:00 UTC.
E1801400That 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.
E1802409The account already has 3 webhook endpoints. Delete one first.
E1803404No webhook with that id on this account. An id that belongs to another account gets the same answer.
E1804503Webhooks are not available right now, so endpoints cannot be created or their secrets rotated. Nothing was changed; try again later.
E1805400The `events` list is not valid: it must be a non-empty list of known event types. The `error` sentence lists them.
API · nfa.pub