Zad Top REST API — reference

An HTTPS JSON API for shops, resellers and automation that sell Free Fire diamonds, PUBG Mobile UC and Yalla Ludo Diamonds / Gold: redeem vouchers you send in the request or vouchers stored in your own stock, resolve a player id to its in-game nickname before a card is spent, read stock counts, request allowance and redeem history, and render the shop's own order receipt as an image. It is the same engine, wallet, stock and history as the Telegram bot — only the interface differs. Messages returned by this API are in English. The game field selects the shop; every other field stays the same.

Protocol HTTPS · JSON · UTF-8 Version /v1/* Base URL this host Auth X-API-Key Bot @ZadTopBot
Code samples

When this page is opened on the API host, ${BASE} and __ORIGIN__ in the samples are rewritten to that server's URL. In your own scripts use https://api.zadtop.com and the key you copied from the bot.

Introduction

There are two call patterns, and every integration uses both:

  • Synchronous calls — one request, final JSON: /health, account and allowance (/v1/me, /v1/quota), stock counts, redeem history, saved players, and player lookup (show the nickname on a checkout screen before a card is spent).
  • Asynchronous jobs — a redeem talks to the shop and needs seconds, so POST answers immediately with a job_id and the work runs in a server-side queue. Read GET /v1/jobs/{job_id}?wait=25 to get the answer the moment the job ends, instead of polling in a loop. This keeps slow upstream calls from timing out your HTTP client.

Keys are created inside the bot (@ZadTopBot) under Subscription → HTTP API → API key, and require an active plan plus the HTTP API add-on. Everything the API writes — jobs, redeem rows, players, receipts — shows up in the bot as well, and the other way round.

Check a card before you redeem it. POST /v1/codes/check reads whether a Free Fire or PUBG card is unused, already used, or never existed — without spending it, and a good card stays good however often you ask. POST /v1/jobs/code-check does the same for a bulk batch. Yalla Ludo has no standalone check: a code is read inside the redeem against the pack you sent, so a mismatch never spends it. Every redeem route also checks its own cards first and reports a dead one without sending it.
A valid card is credited on contact. The Free Fire shop credits a good voucher the instant pay/init sees it, so send each code once through /v1/jobs/instant-redeem and read its outcome from the job. The check route is the dry run; the redeem route is not. Used and expired cards come back as ordinary failed rows (CODE_ALREADY_USED, INVALID_CODE) and cost the same one request as a successful one.
Almost every country is supported. The card is sent to the store that owns the account, so the region is not restricted to the Middle East.
Where Garena sells no vouchers at all — the United States, Argentina, Chile and Russia — player lookup answers 422 UNSUPPORTED_REGION with the player's name and region, and a redeem posted without looking the player up first is refused the same way, one UNSUPPORTED_REGION row per card. No redeem attempt is spent and no card is at risk. A card a store will not accept is a different answer: a region_mismatch row, also not spent.

Quick start

Step 1: prove the host answers. Step 2: send a real key. Step 3: redeem a card and read the outcome. Replace pk_YOUR_KEY_HERE with the key from the bot.

1) Health — no key

Verify TLS + reachability
curl -sS -o /dev/null -w "%{http_code}\n" "${BASE}/health"
# expect: 200 and body {"ok":true}
import requests

BASE = "__ORIGIN__"
r = requests.get(f"{BASE}/health", timeout=30)
r.raise_for_status()
print(r.json())   # {"ok": True}
const BASE = "__ORIGIN__";
const r = await fetch(`${BASE}/health`);
console.log(r.status, await r.json());
<?php
$base = "__ORIGIN__";
echo file_get_contents($base . "/health");
resp, err := http.Get("__ORIGIN__/health")
if err != nil { log.Fatal(err) }
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(resp.StatusCode, string(body))

2) Authenticated ping — GET /v1/quota

Confirm the key, the plan and the requests left
curl -sS "${BASE}/v1/quota" -H "X-API-Key: pk_YOUR_KEY_HERE"
import requests

BASE = "__ORIGIN__"
HEAD = {"X-API-Key": "pk_YOUR_KEY_HERE"}
print(requests.get(f"{BASE}/v1/quota", headers=HEAD, timeout=30).json())
const r = await fetch(`${BASE}/v1/quota`, { headers: { "X-API-Key": "pk_YOUR_KEY_HERE" } });
console.log(await r.json());
<?php
$ctx = stream_context_create(["http" => ["header" => "X-API-Key: pk_YOUR_KEY_HERE\r\n"]]);
echo file_get_contents("__ORIGIN__/v1/quota", false, $ctx);
req, _ := http.NewRequest("GET", "__ORIGIN__/v1/quota", nil)
req.Header.Set("X-API-Key", "pk_YOUR_KEY_HERE")
resp, err := http.DefaultClient.Do(req)

401 means the header is missing or the key was replaced. 403 means the account has no active plan + API add-on. See Authentication.

3) First redeem — queue, then wait

POST /v1/jobs/instant-redeem → GET /v1/jobs/{id}?wait=25
JOB=$(curl -sS -X POST "${BASE}/v1/jobs/instant-redeem" \
  -H "X-API-Key: pk_YOUR_KEY_HERE" \
  -H "Content-Type: application/json" \
  -d '{"player_id":"123456789","codes":["4233939008625064"],"game":"freefire"}' \
  | sed -n 's/.*"job_id":"\([^"]*\)".*/\1/p')

curl -sS "${BASE}/v1/jobs/$JOB?wait=25" -H "X-API-Key: pk_YOUR_KEY_HERE"
import requests

BASE = "__ORIGIN__"
HEAD = {"X-API-Key": "pk_YOUR_KEY_HERE", "Content-Type": "application/json"}

job = requests.post(
    f"{BASE}/v1/jobs/instant-redeem",
    headers=HEAD,
    json={"player_id": "123456789", "codes": ["4233939008625064"], "game": "freefire"},
    timeout=30,
).json()

done = requests.get(f"{BASE}/v1/jobs/{job['job_id']}?wait=25", headers=HEAD, timeout=40).json()
for row in (done.get("result") or {}).get("results", []):
    print(row["code"], row["success"], row.get("err_code"), row.get("total"))
const HEAD = { "X-API-Key": "pk_YOUR_KEY_HERE", "Content-Type": "application/json" };

const job = await (await fetch(`${BASE}/v1/jobs/instant-redeem`, {
  method: "POST",
  headers: HEAD,
  body: JSON.stringify({ player_id: "123456789", codes: ["4233939008625064"], game: "freefire" }),
})).json();

const done = await (await fetch(`${BASE}/v1/jobs/${job.job_id}?wait=25`, { headers: HEAD })).json();
console.log(done.status, done.result?.ok_count, done.result?.results);
<?php
$base = "__ORIGIN__";
$head = "X-API-Key: pk_YOUR_KEY_HERE\r\nContent-Type: application/json\r\n";
$body = json_encode(["player_id" => "123456789", "codes" => ["4233939008625064"], "game" => "freefire"]);

$ctx = stream_context_create(["http" => ["method" => "POST", "header" => $head, "content" => $body]]);
$job = json_decode(file_get_contents("$base/v1/jobs/instant-redeem", false, $ctx), true);

$ctx2 = stream_context_create(["http" => ["header" => $head]]);
echo file_get_contents("$base/v1/jobs/{$job['job_id']}?wait=25", false, $ctx2);
payload := strings.NewReader(`{"player_id":"123456789","codes":["4233939008625064"],"game":"freefire"}`)
req, _ := http.NewRequest("POST", BASE+"/v1/jobs/instant-redeem", payload)
req.Header.Set("X-API-Key", apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
// decode {"job_id":"...","status":"pending"} then GET /v1/jobs/{id}?wait=25

Conventions

Voucher codesFree Fire vouchers are exactly 16 digits (0–9). Spaces and dashes in the string are stripped before validation, so 4233-9390-0862-5064 is accepted. Yalla Ludo cards are 12 letters/digits and can be stored, but not redeemed yet.
Player idDigits only, 6–12 characters for Free Fire. Always sent and returned as a string.
FormatRequests and responses are application/json, UTF-8. The one exception is POST /v1/order/screenshot, which returns raw PNG bytes (image/png) on success.
TimestampsFields ending in _stamp are Unix seconds (UTC). time_created is a human-readable server string. Daily allowance resets on the UTC calendar day.
IDsjob_id, history_id and history id are MongoDB ObjectId strings (24 hex characters).
OwnershipEvery route is scoped to the account behind the key. A job_id or history_id from another account answers 404, never someone else's data.
IdempotencyThere is none, by design: each POST to a job route creates a new job. Posting the same codes twice queues two redeems — deduplicate on your side before sending.
VersioningThe /v1/ prefix allows additive changes. A breaking change would ship under a new prefix. /api/v1/order/screenshot is kept as an alias of /v1/order/screenshot.
OpenAPIMachine-readable schema: /openapi.json · interactive UI: /swagger · ReDoc: /redoc.

Authentication

Every route below except GET /health requires one header:

X-API-Key: pk_YOUR_KEY_HERE
Getting a keyIn @ZadTopBot: Subscription → HTTP API → API key → New key. The key is shown once; creating a new one revokes the old one immediately.
EligibilityAn active plan (p1…p6) and an active HTTP API add-on, or an admin-granted API trial. Missing either one → 403 with the text explaining what to buy.
Invalid key401 — Missing X-API-Key header or Invalid or revoked API key.
One key, one accountThe key maps to the Telegram account that owns the wallet, the stock and the history. Requests appear in that account's history and alerts.
SecurityKeep the key server-side. Never ship it in a browser bundle or a mobile app — anyone holding it can spend your stock and your allowance. If it leaks, create a new key in the bot; the old one dies at once.
Audit trailCalls to /v1/* and /api/v1/* are logged with method, path, status, duration and a truncated body, and are visible to the account owner.

Quota & billing (requests)

Usage is counted in requests, the same unit the bot shows. A redeem costs 1 request per code, charged when the worker runs — not when you POST. A card that turns out to be used or invalid is a real answer about that card, so it is charged like any other; a card we never managed to ask about is not.

Spending orderFree-trial requests first, then the combined daily allowance of active plans, then an admin API trial if one was granted.
Daily resetThe daily counter (usage_today) resets at the UTC day boundary.
StackingSeveral active plans add up: daily_limit_combined is their sum, so a cap above the top tier's 10 000/day is possible.
instant-redeem1 request per code in codes (max 10 per job).
stock-redeem1 request per card actually reserved from your stock.
What decides a chargeWhose problem it was, not how welcome the answer is. A refusal from the shop is the answer you asked for and is charged. A failure on our side is not.
player/lookup0.25 requests for every answer the shop gives: a name (200), an id it has never heard of (404), or a country with no vouchers (422). Only 502 LOOKUP_FAILED — where we never got a verdict — is free, as are the 400 refusals, which are rejected on format before the shop is touched. Every one of these responses carries quota_requests_charged.
codes/check · jobs/code-check1 request per card the shop gave a verdict on — the same as a redeem, because a read holds a shop session just as a redeem does. valid, used, invalid, expired, inactive, blocked and region_mismatch are all verdicts. A card it would not judge (unknown, error) costs nothing.
Refunded automaticallyA card that failed for a reason that is not the card's own — NETWORK, SESSION_EXPIRED, CAPTCHA, CAPTCHA_FAILED, RATE_LIMITED, SHOP_BLOCKED, REDEEM_FAILED — is credited back to your allowance when the job finishes. CODE_ALREADY_USED, INVALID_CODE, REGION_MISMATCH and PLAYER_NOT_FOUND are real answers from the shop and stay charged.
Free routes/health, /v1/me, /v1/quota, /v1/stock/summary, /v1/history, /v1/players, /v1/jobs… reads and /v1/order/screenshot do not spend requests.
Not enough leftThe two shapes differ. A job is accepted and then finishes as failed with error: "quota_exhausted:…"; nothing is redeemed and reserved stock is released. A synchronous paid route (player/lookup, codes/check) is refused up front with 429 QUOTA_EXHAUSTED and never reaches the shop. Check /v1/quota first for large batches.
FractionsAccounting is fractional, so lookups and redeems mix cleanly (e.g. 4 lookups = 1 request).
Next to the botOne allowance serves both. A redeem is 1 per code and a card read 1 per card in either place, so neither is the cheaper door. A lookup has no price in the bot: there the name is resolved as a step of a redeem you are already paying 1 per code for, and charging it again would bill one request twice. This endpoint is a lookup you asked for on its own, so it is priced on its own.

Plans

plan_keyRequests / UTC dayPrice
p1255 USDT / 30 days
p220010 USDT / 30 days
p31 00025 USDT / 30 days
p42 50050 USDT / 30 days
p55 00085 USDT / 30 days
p610 000135 USDT / 30 days
API add-onunlocks HTTP access15 USDT / 30 days

Prices are charged from the wallet balance. The bot's Subscription menu is always the authoritative list.

Rate limits & concurrency

Per-account redeem lanesUp to 4 concurrent shop operations per account. A fifth job waits in the queue instead of being rejected, so you can post a burst safely.
Server workersThe queue is drained by 16 workers by default, shared by all accounts. Jobs are claimed oldest-first.
Lookup/v1/player/lookup waits up to 15s for a free lane, then answers 429 RATE_LIMIT. Retry after a moment.
Screenshot15 renders per minute per key → 429 SCREENSHOT_RATE_LIMIT with Retry-After: 60.
Global throttleAn optional per-minute cap on /v1/* and /api/v1/* (429 RATE_LIMIT, Retry-After: 60). Off unless the operator enables it.
Polling etiquetteUse ?wait=25 rather than a tight loop. Without wait, poll no faster than once per second.
Safe restartWhile the service drains for a restart, new job posts answer 503 SERVICE_DRAINING and running jobs are finished first. Reads keep working. Retry after a few seconds.

HTTP status reference

Errors are JSON with a detail field — either a string (auth) or an object {"error": "CODE", "message": "…"}. Log the status line together with the body.

CodeWhenWhat to do
200Parsed fine. For jobs this only means the document was read — branch on status inside the JSON. For the screenshot route the body is binary PNG, not JSON.Inspect the payload, not just the status.
400Validation: INVALID_PLAYER_ID, INVALID_CODE, UNKNOWN_GAME, MISSING_PICKS, GAME_REDEEM_UNAVAILABLE, GAME_LOOKUP_UNAVAILABLE, screenshot selection errors, or a rejected purchase.Compare the body with this page or /openapi.json.
401X-API-Key missing, misspelled, or revoked by creating a newer key.Re-copy the key from the bot.
403No active plan + API add-on (and no admin trial).Renew in the bot, then retry; eligibility is cached for a few seconds.
404PLAYER_NOT_FOUND, unknown job_id, or HISTORY_NOT_FOUND — including ids that belong to another account.Verify the id; list jobs or history.
409JOB_NOT_READY — a screenshot was asked for while the job is still pending/running.Wait for the job, then retry.
422Body did not match the schema (wrong type, missing field, more than 10 codes).Read the FastAPI validation detail; fix the payload.
429RATE_LIMIT (all lanes busy or global throttle) or SCREENSHOT_RATE_LIMIT.Honour Retry-After: 60; add backoff.
500ORDER_SCREENSHOT_FAILED or an unexpected server error.Retry once; include the message when contacting support.
502LOOKUP_FAILED — the shop could not be reached for a lookup. Nothing charged.Retry later. Redeem does not use this path; it reports per-code failures instead.
503SERVICE_DRAINING — a safe restart is in progress and new jobs are refused.Retry in a few seconds; nothing was queued.

Response catalog (status, job, per-code outcome)

Treat three layers separately. A call can return HTTP 200 with job status: "done" while individual cards failed.

HTTP layerStatus line + detail — auth, validation, throttles, ownership.
Job layerstatus and error on GET /v1/jobs/{id} — the queue lifecycle.
Outcome layerresult.ok_count, result.failed_count and each result.results[].success / err_code — the business result, per card.
Critical: status: "done" means the worker finished, not that diamonds were credited. Partial batches are normal: always loop over result.results[].

Structured detail.error codes

errorHTTPRoutesMeaning
INVALID_PLAYER_ID400lookup, redeem jobsDoes not match the chosen game (see Games).
INVALID_CODE400instant-redeem, codes/checkA code does not match the chosen game after normalisation.
TOO_MANY_CODES400codes/check, code-checkMore codes than the route takes — limit and sent are in the body.
UNKNOWN_GAME400all game routesgame is not freefire, pubg, ludo, ludogold or jawaker.
MISSING_TIER400instant-redeemYalla Ludo was posted without a resolvable tier (quantity or Diamonds USD price).
GAME_REDEEM_UNAVAILABLE400redeem / checkThis game cannot be redeemed, or standalone check was asked for Yalla Ludo.
GAME_LOOKUP_UNAVAILABLE400player/lookupLookup is not offered for this game.
MISSING_PICKS400stock-redeempicks was empty.
PLAYER_NOT_FOUND404player/lookupNo account with that id for the chosen game.
UNSUPPORTED_REGION422player/lookupThe account exists but cannot be served: Free Fire in a country where Garena sells no vouchers, or a PUBG account the shop refuses. region and player_name are included. Charged, because the shop gave the verdict.
LOOKUP_FAILED502player/lookupUpstream failure; no quota charged.
RATE_LIMIT429lookup, audited routesLanes busy or global throttle. Retry-After: 60.
QUOTA_EXHAUSTED429player/lookup, codes/checkNot enough daily requests left to pay for this call, so nothing was done. needed and remaining are in the body. Unlike RATE_LIMIT, waiting a minute does not help: the pool refills at midnight UTC, or when you buy a plan. The job routes do not use this — they accept the job and fail it with quota_exhausted:… instead.
GAME_NOT_IN_PLAN403player/lookup, codes/check, every job routeNone of your active plans was bought for this game, and no trial is left to pay for it. Nothing was done. Add the game to a plan in the bot (you pay the price difference for the days left), or buy a plan that covers it.
SCREENSHOT_RATE_LIMIT429order/screenshotMore than 15 renders in a minute for this key.
MISSING_JOB_OR_HISTORY_ID400order/screenshotNeither job_id nor history_id was sent.
JOB_NOT_FOUND404order/screenshotNo such job for this key.
HISTORY_NOT_FOUND404order/screenshotNo such history row for this key.
JOB_NOT_READY409order/screenshotJob still pending/running.
JOB_NOT_SUCCESSFUL400order/screenshotJob ended failed.
NO_ORDER_DETAILS400order/screenshotresult_index points at no row, or at a failed one.
ORDER_SCREENSHOT_FAILED500order/screenshotRender failed unexpectedly.
SERVICE_DRAINING503redeem jobsSafe restart; nothing queued.
NEED_PLAN · INSUFFICIENT_BALANCE400subscription routesBuy a plan first, or top up the wallet.

Auth failures use a plain string detail: Missing X-API-Key header, Invalid or revoked API key, or the subscription explanation.

Job lifecycle

A create call returns {"job_id":"…","status":"pending"}. Poll until a terminal state.

statuspending → running → done or failed
resultPresent when the worker stored an outcome (normally on done). Sanitised — no sessions, proxies or internal detail.
errorA short string when status is failed; null otherwise.
Timingcreated_at_stamp, started_at_stamp, finished_at_stamp (Unix seconds).
Job error (status failed)MeaningClient action
quota_exhausted:…Not enough requests left when the worker ran. Nothing was redeemed; reserved stock was released.Read /v1/quota; wait for the UTC reset or buy a plan.
invalid_player_idPlayer id rejected by the worker.Validate before posting.
no_codesNo valid code survived normalisation, or stock held none of the requested packs.Check the codes, or /v1/stock/summary.
invalid_payloadpicks could not be read as pack → quantity.Send integers keyed by pack size.
unknown_game · game_redeem_unavailableGame slug unknown, or this game cannot be redeemed.Use freefire, pubg, ludo, ludogold or jawaker.
tier_requiredYalla Ludo job had no pack, or the pack belongs to the other Yalla store.Send tier as a quantity or Diamonds USD price; Gold by quantity with game=ludogold.
interrupted:process_restartThe process died mid-job. Never auto-retried, because the card may already have reached the shop.Check /v1/history for that code before resending.
unknown_job_typeInternal mismatch (rare).Contact support with the job_id.
anything elseAn unexpected fault inside the worker, reported as ExceptionName: message. It is not one of the strings above, so match it last rather than switching on it.Treat as interrupted: check /v1/history for the codes before resending, then contact support with the job_id.

Per-code err_code (job done)

Each row of result.results[] carries success and, on failure, an err_code.

err_codesuccessMeaningRecommended handling
nulltrueDiamonds credited. The row carries display_id, amount, bonus, total.Show the receipt; keep display_id as the order number.
CODE_ALREADY_USEDfalseThe card was already redeemed — by you earlier, or by whoever had it before.Retire the code. Never retry; it will never succeed.
INVALID_CODEfalseThe shop does not recognise the card (wrong or expired).Retire the code; verify how it was typed.
REGION_MISMATCHfalseThe card belongs to another region than the player.Use it for a player in the matching region.
UNSUPPORTED_REGIONfalseGarena sells no vouchers in the player's country, so their store has no voucher channel to send one to. Every row of the batch carries it. Permanent — no code of any region will work for this player.Stop retrying this player. The cards are untouched; use them for someone else.
PLAYER_NOT_FOUNDfalseThe shop has no account with this player id. Every card in the batch carries it, because the id is what failed, not any card.Run lookup before redeeming. The cards are untouched and can be resent to a correct id.
RATE_LIMITEDfalseThe shop throttled this attempt.Retry the same code later with backoff.
CAPTCHA · CAPTCHA_FAILEDfalseA challenge appeared and was not solved.Retry later; the card was not spent.
SHOP_BLOCKEDfalseThe shop refused the route used for this attempt.Retry later; contact support if it persists.
NETWORK · SESSION_EXPIREDfalseTransport or session problem before a result was known.Retry the same code; check history first if you want certainty.
REDEEM_FAILEDfalseThe shop refused for a reason with no specific mapping.Show the neutral message, retry once, then escalate.
In stock-redeem, a card that failed for a reason that is not the card's fault (network, captcha, rate limit, session, shop block) is released back into your stock. Used and invalid cards are marked failed so they are never picked again.

Example — done, with one card refused

{ "id": "67ff1a2b3c4d5e6f70819200", "type": "instant_redeem", "status": "done", "error": null, "result": { "game": "freefire", "player_id": "123456789", "success": false, "ok_count": 1, "failed_count": 1, "credited": 110, "point_name": "Diamonds", "nickname": "ZadTopPlayer", "history_id": "67ff1a2b3c4d5e6f70819270", "results": [ { "ok": true, "success": true, "code": "4233939008625064", "player_id": "123456789", "nickname": "ZadTopPlayer", "display_id": "FF2409091234567", "amount": 100, "bonus": 10, "total": 110, "point_name": "Diamonds", "currency": "USD", "price": 0.99, "receipt_time": "2026-09-12 13:31:04", "err_code": null, "outcome": "ok" }, { "ok": false, "success": false, "code": "4233939008625065", "player_id": "123456789", "err_code": "CODE_ALREADY_USED", "outcome": "used_card" } ] }, "created_at_stamp": 1789123840, "started_at_stamp": 1789123841, "finished_at_stamp": 1789123864 }

Example — failed on quota

{ "id": "67ff1a2b3c4d5e6f70819202", "type": "stock_redeem", "status": "failed", "result": null, "error": "quota_exhausted:quota_exhausted" }

GET /health

Liveness probe for load balancers and uptime monitors. No authentication, no quota, no shop contact.

Response200 · {"ok": true}
Use caseUptime checks, TLS verification, deployment smoke test.
HTTP/1.1 200 OK Content-Type: application/json {"ok": true}

GET /v1/me

The account behind the key: wallet balance, the plan in force, today's usage, and whether HTTP access is active. Free to call — a good startup check for a dashboard.

Querynone
Quotafree
has_api_subscriptionfalse means the add-on lapsed; redeem routes will answer 403 once the eligibility cache expires.
{ "chat_id": 123456789, "balance_usdt": 42.5, "subscription": { "plan_key": "p2", "daily_limit": 200, "usage_today": 17, "trial_remaining": 0 }, "has_api_subscription": true }
GET /v1/me
curl -sS "${BASE}/v1/me" -H "X-API-Key: pk_YOUR_KEY_HERE"
print(requests.get(f"{BASE}/v1/me", headers=HEAD, timeout=30).json())
console.log(await (await fetch(`${BASE}/v1/me`, { headers: HEAD })).json());
<?php
$ctx = stream_context_create(["http" => ["header" => "X-API-Key: pk_YOUR_KEY_HERE\r\n"]]);
echo file_get_contents("__ORIGIN__/v1/me", false, $ctx);
req, _ := http.NewRequest("GET", BASE+"/v1/me", nil)
req.Header.Set("X-API-Key", apiKey)

GET /v1/quota

What you can still spend today. Call it before a large batch so you do not queue jobs that will fail on quota.

trial_remainingFree-trial requests left (spent first).
daily_limit_combinedSum of the daily allowances of all active plans.
usage_todaySpent since the last UTC midnight.
daily_left_subscriptionWhat today's plans still allow. With ?game=pubg, only the plans that cover that game count.
api_trial_remainingAdmin-granted API trial requests, if any.
total_requests_left_approxThe practical number to budget against.
{ "trial_remaining": 0, "daily_limit_combined": 200, "usage_today": 17, "daily_left_subscription": 183, "api_trial_remaining": 0, "total_requests_left_approx": 183 }

GET /v1/history

Redeem history, one row per code, newest first — and it covers everything the account did, whether the redeem came from this API or from inside Telegram. This is the audit trail to reconcile against your own orders, and the place to look before resending a code you are unsure about.

Querylimit 1–100 (default 20) · offset ≥ 0 · player_id substring · code substring · days 7, 30 or 90 (omit for all time)
Filtersplayer_id and code match on substrings, so the last four digits of a card are enough. They combine with days using AND.
Pagingtotal is the count after filtering — page with offset += limit.
statusok or failed. outcome carries the finer engine reason (used_card, invalid_card, network, …).
history_idThe batch this row belongs to — pass it to order/screenshot to draw the receipt.
Quotafree
GET /v1/history?player_id=123456789&code=5064&days=30&limit=50&offset=0 { "items": [ { "id": "67ff1a2b3c4d5e6f70819277", "history_id": "67ff1a2b3c4d5e6f70819270", "player_id": "123456789", "nickname": "ZadTopPlayer", "game": "freefire", "code": "4233939008625064", "status": "ok", "outcome": "ok", "amount": 100, "bonus": 10, "total": 110, "display_id": "FF2409091234567", "source": "api", "point_name": "Diamonds", "time_created": "2026-09-12 10:31:04", "time_stamp": 1789123864 } ], "limit": 50, "offset": 0, "total": 1, "filters": { "player_id": "123456789", "code": "5064", "days": 30 } }
Page through the last 30 days for one player
curl -sS "${BASE}/v1/history?player_id=123456789&days=30&limit=100&offset=0" \
  -H "X-API-Key: pk_YOUR_KEY_HERE"
offset, rows = 0, []
while True:
    page = requests.get(
        f"{BASE}/v1/history",
        headers=HEAD,
        params={"player_id": "123456789", "days": 30, "limit": 100, "offset": offset},
        timeout=30,
    ).json()
    rows += page["items"]
    offset += page["limit"]
    if offset >= page["total"]:
        break
print(len(rows), "codes")
const q = new URLSearchParams({ player_id: "123456789", days: "30", limit: "100", offset: "0" });
const page = await (await fetch(`${BASE}/v1/history?${q}`, { headers: HEAD })).json();
console.log(page.total, page.items.length);
<?php
$q = http_build_query(["player_id" => "123456789", "days" => 30, "limit" => 100]);
$ctx = stream_context_create(["http" => ["header" => "X-API-Key: pk_YOUR_KEY_HERE\r\n"]]);
echo file_get_contents("__ORIGIN__/v1/history?$q", false, $ctx);
req, _ := http.NewRequest("GET", BASE+"/v1/history?player_id=123456789&days=30&limit=100", nil)
req.Header.Set("X-API-Key", apiKey)

GET /v1/players

Every player this account has looked up or redeemed to, newest first — a ready-made customer list for a shop front end. Written by both the bot and the API, so a nickname resolved in Telegram is available here too.

Querylimit 1–200 (default 50)
OrderMost recently seen first (last_seen_stamp).
Quotafree — cache this instead of repeating a paid lookup for a known player.
{ "items": [ { "player_id": "123456789", "nickname": "ZadTopPlayer", "game": "freefire", "region": "ME", "last_seen_stamp": 1789123864 } ] }

GET /v1/player/lookup

Resolves a numeric player_id to the in-game nickname through the same shop a redeem uses — Free Fire, PUBG Mobile or Yalla Ludo. Show it on the checkout screen so the buyer confirms the id before a card is spent.

Queryplayer_id required · game freefire (default), pubg, ludo, ludogold or jawaker. Id rules follow the game — see Games.
Quota0.25 requests for any verdict the shop gives — 200, 404 or 422. Free on 502, and on the 400s, which never reach the shop.
Side effectThe player is stored and appears in /v1/players and in the bot.
Errors400 INVALID_PLAYER_ID · 400 UNKNOWN_GAME · 404 PLAYER_NOT_FOUND · 422 UNSUPPORTED_REGION · 429 RATE_LIMIT (no free lane within 15s) · 429 QUOTA_EXHAUSTED (fewer than 0.25 requests left) · 502 LOOKUP_FAILED

Success — HTTP 200

{ "ok": true, "player_id": "123456789", "player_name": "ZadTopPlayer", "region": "ME", "quota_requests_charged": 0.25 }

Unknown player — HTTP 404

The shop looked and this id is not one of its accounts. That is a verdict, so it is charged — otherwise guessing player IDs would be an unlimited free service.

{ "detail": { "error": "PLAYER_NOT_FOUND", "message": "The shop has no account with this player ID.", "quota_requests_charged": 0.25 } }

A country where Garena sells no vouchers — HTTP 422

The account is real and named, but there is no voucher to redeem onto it in its country. Tell the buyer here rather than letting a card fail later — the region is a fixed property of a Free Fire account and will not change. Almost every country is served; this is the narrow exception.

{ "detail": { "error": "UNSUPPORTED_REGION", "message": "Garena does not sell vouchers in this player's country.", "player_id": "2185663214", "player_name": "ZadTopPlayer", "region": "US", "quota_requests_charged": 0.25 } }
GET /v1/player/lookup?player_id=…
curl -sS "${BASE}/v1/player/lookup?player_id=123456789&game=freefire" \
  -H "X-API-Key: pk_YOUR_KEY_HERE"
r = requests.get(
    f"{BASE}/v1/player/lookup",
    headers=HEAD,
    params={"player_id": "123456789", "game": "freefire"},
    timeout=60,
)
if r.status_code == 200:
    print("nickname:", r.json()["player_name"])
elif r.status_code == 404:
    print("no such player")
else:
    print("lookup unavailable:", r.status_code, r.text)
const r = await fetch(`${BASE}/v1/player/lookup?player_id=123456789`, { headers: HEAD });
if (r.ok) console.log((await r.json()).player_name);
else console.log("lookup failed", r.status, await r.text());
<?php
$ctx = stream_context_create(["http" => ["header" => "X-API-Key: pk_YOUR_KEY_HERE\r\n", "ignore_errors" => true]]);
echo file_get_contents("__ORIGIN__/v1/player/lookup?player_id=123456789", false, $ctx);
req, _ := http.NewRequest("GET", BASE+"/v1/player/lookup?player_id=123456789", nil)
req.Header.Set("X-API-Key", apiKey)
resp, err := http.DefaultClient.Do(req)

POST /v1/codes/check

Reads whether each card is unused, already used, or was never real — without redeeming anything. The storefront's own Redeem page reads a card before it lets the visitor pick a game, and that endpoint only reads: a valid code is still valid however often it is checked. Verified the hard way — one card checked repeatedly, then redeemed, then checked again, which is when it turned to used.

Bodycodes 1–20 · game freefire or pubg · with_value true to price them too. Yalla Ludo and Jawaker answer GAME_REDEEM_UNAVAILABLE.
Quota1 request per card the shop answered for, the same as a redeem. A card it never answered about is not charged.
Larger batchesUp to 200 cards through POST /v1/jobs/code-check, which is polled like a redeem job.
Side effectnone. Nothing is redeemed, no card is consumed, no stock row changes.
Errors400 INVALID_CODE · 400 TOO_MANY_CODES · 429 RATE_LIMIT · 429 QUOTA_EXHAUSTED (the batch costs more than you have left; nothing is read) · 503 SERVICE_DRAINING

What each status means

statusMeaningredeemable
validThe card exists and has not been used.true
usedAlready redeemed, by anyone.false
invalidNo such card — a typo or an invention.false
expired · inactive · blockedThe shop's own refusals for a card that exists but cannot be used.false
region_mismatchThe card belongs to a different storefront than the one it was read under.false
unknown · errorWe could not find out. Treat as not yet known, not as bad — send it to a redeem if you want an answer.false
with_value is not a status. shell_amount (face value in Garena Shells) and amount (the pack it credits — 100, 210, 530, 1080, 2200) are quoted the same for a card that has already been used, so a value coming back never means the card is live. Always read the verdict above.

Success — HTTP 200

{ "ok": true, "checked": 3, "counts": { "valid": 1, "used": 1, "invalid": 1 }, "quota_requests_charged": 0.3, "results": [ { "code": "7397062240255931", "status": "valid", "redeemable": true, "error": null, "shell_amount": 50, "amount": 100, "bonus": 0 }, { "code": "5539151276341494", "status": "used", "redeemable": false, "error": "error_used_card", "shell_amount": 50, "amount": 100, "bonus": 0 }, { "code": "0000000000000000", "status": "invalid", "redeemable": false, "error": "error_invalid_card", "shell_amount": null, "amount": null, "bonus": null } ] }
POST /v1/codes/check
curl -sS -X POST "${BASE}/v1/codes/check" \
  -H "X-API-Key: pk_YOUR_KEY_HERE" -H "Content-Type: application/json" \
  -d '{"codes":["7397062240255931","5539151276341494"],"game":"freefire"}'
r = requests.post(
    f"{BASE}/v1/codes/check",
    headers=HEAD,
    json={"codes": ["7397062240255931", "5539151276341494"]},
    timeout=60,
)
for row in r.json()["results"]:
    print(row["code"], row["status"], "-> redeem" if row["redeemable"] else "-> skip")
const r = await fetch(`${BASE}/v1/codes/check`, {
  method: "POST",
  headers: { ...HEAD, "Content-Type": "application/json" },
  body: JSON.stringify({ codes: ["7397062240255931", "5539151276341494"] }),
});
const { results } = await r.json();
const good = results.filter((x) => x.redeemable).map((x) => x.code);
<?php
$body = json_encode(["codes" => ["7397062240255931", "5539151276341494"]]);
$ctx = stream_context_create(["http" => ["method" => "POST", "content" => $body,
  "header" => "X-API-Key: pk_YOUR_KEY_HERE\r\nContent-Type: application/json\r\n", "ignore_errors" => true]]);
echo file_get_contents("__ORIGIN__/v1/codes/check", false, $ctx);
body := []byte(`{"codes":["7397062240255931","5539151276341494"]}`)
req, _ := http.NewRequest("POST", BASE+"/v1/codes/check", bytes.NewReader(body))
req.Header.Set("X-API-Key", apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)

GET /v1/stock/summary

How many vouchers the account holds, per pack. Only available cards are counted — reserved, used and failed ones are left out — so this is exactly what stock-redeem can draw from right now.

Querynone
ShapeOne object per game slug, each with name, redeemable, by_denomination (pack size → count) and total.
GamesOne object per slug the account can hold: freefire, pubg, ludo, ludogold, jawaker. All five are redeemable: true.
Quotafree
{ "freefire": { "name": "Free Fire", "redeemable": true, "by_denomination": { "100": 12, "210": 4, "530": 0, "1080": 2, "2200": 0 }, "total": 18 }, "ludo": { "name": "Yalla Ludo Diamonds", "redeemable": true, "by_denomination": { "830": 5, "2320": 0, "5150": 0, "13580": 0, "27640": 0, "55800": 0, "168860": 0, "283460": 0 }, "total": 5 }, "ludogold": { "name": "Yalla Ludo Gold", "redeemable": true, "by_denomination": { "68500": 2, "223700": 0, "1463320": 0, "3666470": 0, "9973990": 0, "25236460": 0, "76000860": 0, "126910990": 0 }, "total": 2 }, "pubg": { "name": "PUBG Mobile", "redeemable": true, "by_denomination": { "60": 8, "325": 1, "660": 0, "1800": 0, "3850": 0, "8100": 0 }, "total": 9 }, "jawaker": { "name": "Jawaker", "redeemable": true, "by_denomination": { "4250": 6, "32500": 1, "70000": 0, "150000": 0, "230000": 0, "400000": 0, "525000": 0, "805000": 0, "825000": 0 }, "total": 7 } }

Asynchronous jobs

Redeeming means a real conversation with the shop, so it runs in a queue. POST validates the payload, stores a job and returns at once; a worker then reserves quota, performs the redeem and writes the result plus the history rows.

CreatePOST /v1/jobs/instant-redeem · POST /v1/jobs/stock-redeem
ReadGET /v1/jobs/{job_id} (optionally ?wait=) · GET /v1/jobs
Billing momentWhen the worker starts the batch — a queued job that never runs costs nothing.
OrderingOldest job first, up to 4 in parallel per account.
Typical latencyA single code usually settles in a few seconds; a batch is roughly linear, with a short human-like pause between cards.
Restart safetyA job interrupted by a restart ends as failed with interrupted:process_restart and is never retried automatically — check history before resending that code.

Immediate response after POST

{ "job_id": "67ff1a2b3c4d5e6f70819200", "status": "pending" }

Still working

{ "id": "67ff1a2b3c4d5e6f70819200", "type": "instant_redeem", "status": "running", "result": null, "error": null, "created_at_stamp": 1789123840, "started_at_stamp": 1789123841, "finished_at_stamp": null }

Wait for the result instead of polling

Long-poll pattern with a hard deadline
for i in $(seq 1 6); do
  OUT=$(curl -sS "${BASE}/v1/jobs/$JOB?wait=25" -H "X-API-Key: pk_YOUR_KEY_HERE")
  echo "$OUT" | grep -q '"status":"\(done\|failed\)"' && { echo "$OUT"; break; }
done
import time
import requests

def wait_job(base, head, job_id, deadline_sec=180):
    end = time.time() + deadline_sec
    while time.time() < end:
        doc = requests.get(f"{base}/v1/jobs/{job_id}?wait=25", headers=head, timeout=40).json()
        if doc.get("status") in ("done", "failed"):
            return doc
    raise TimeoutError(job_id)

doc = wait_job(BASE, HEAD, "67ff1a2b3c4d5e6f70819200")
res = doc.get("result") or {}
print(doc["status"], res.get("ok_count"), res.get("failed_count"))
async function waitJob(jobId, deadlineMs = 180000) {
  const end = Date.now() + deadlineMs;
  while (Date.now() < end) {
    const doc = await (await fetch(`${BASE}/v1/jobs/${jobId}?wait=25`, { headers: HEAD })).json();
    if (doc.status === "done" || doc.status === "failed") return doc;
  }
  throw new Error("timeout " + jobId);
}
<?php
function wait_job($base, $key, $jobId, $deadline = 180) {
  $end = time() + $deadline;
  $ctx = stream_context_create(["http" => ["header" => "X-API-Key: $key\r\n"]]);
  while (time() < $end) {
    $doc = json_decode(file_get_contents("$base/v1/jobs/$jobId?wait=25", false, $ctx), true);
    if (in_array($doc["status"], ["done", "failed"], true)) return $doc;
  }
  throw new RuntimeException("timeout");
}
for deadline := time.Now().Add(3 * time.Minute); time.Now().Before(deadline); {
    req, _ := http.NewRequest("GET", BASE+"/v1/jobs/"+jobID+"?wait=25", nil)
    req.Header.Set("X-API-Key", apiKey)
    resp, err := http.DefaultClient.Do(req)
    if err != nil { continue }
    // decode; break when status is done or failed
    resp.Body.Close()
}

Many orders at once

Do not put unrelated customers in one job: a job is one player_id. Post one job per order and poll them in parallel. Four of your jobs run at a time and the rest queue, so a burst of fifty orders is accepted immediately and drains in order. Keep the job_id next to your own order id, and reconcile later through /v1/history.

POST /v1/jobs/instant-redeem

Redeems the codes you send in the request onto one player. This is the route a shop uses when the customer (or your own supplier file) provides the card.

Bodyplayer_id · codes 1–10 · game freefire | pubg | ludo | ludogold | jawaker · tier required for Yalla Ludo (quantity or Diamonds USD price). Id and code shapes follow the game — see Games.
NormalisationSpaces, dashes and case are stripped from each code before validation.
Quota1 request per code, charged when the worker runs.
Errors at POST400 INVALID_PLAYER_ID / INVALID_CODE / UNKNOWN_GAME / GAME_REDEEM_UNAVAILABLE · 422 schema · 503 SERVICE_DRAINING
Resultok_count, failed_count, credited, nickname, history_id, and one results[] row per code in the order you sent them.
A valid card is credited on the first attempt, so send a code here only when you intend to spend it. Use /v1/codes/check when you only want to know what a card is. Use err_code to decide whether a failure may be retried (network, captcha, rate limit → retry; used, invalid, region → retire).

Every card in the job is read first through the same check as /v1/codes/check. A card the shop already calls used or invalid comes back as that failed row without a redeem attempt being spent on it, so a batch of dead codes can no longer exhaust the player's attempt budget. A card the check cannot judge is redeemed normally.

Request — one card

{ "player_id": "123456789", "codes": ["4233939008625064"], "game": "freefire" }

Request — three cards for the same player in one job

{ "player_id": "123456789", "codes": ["4233939008625064", "4233939008625065", "4233939008625066"], "game": "freefire" }
POST /v1/jobs/instant-redeem
curl -sS -X POST "${BASE}/v1/jobs/instant-redeem" \
  -H "X-API-Key: pk_YOUR_KEY_HERE" \
  -H "Content-Type: application/json" \
  -d '{"player_id":"123456789","codes":["4233939008625064"],"game":"freefire"}'
job = requests.post(
    f"{BASE}/v1/jobs/instant-redeem",
    headers=HEAD,
    json={"player_id": "123456789", "codes": ["4233939008625064"], "game": "freefire"},
    timeout=30,
).json()
print(job["job_id"], job["status"])
const job = await (await fetch(`${BASE}/v1/jobs/instant-redeem`, {
  method: "POST",
  headers: HEAD,
  body: JSON.stringify({ player_id: "123456789", codes: ["4233939008625064"], game: "freefire" }),
})).json();
<?php
$body = json_encode(["player_id" => "123456789", "codes" => ["4233939008625064"], "game" => "freefire"]);
$ctx = stream_context_create(["http" => [
  "method" => "POST",
  "header" => "X-API-Key: pk_YOUR_KEY_HERE\r\nContent-Type: application/json\r\n",
  "content" => $body,
]]);
echo file_get_contents("__ORIGIN__/v1/jobs/instant-redeem", false, $ctx);
payload := strings.NewReader(`{"player_id":"123456789","codes":["4233939008625064"],"game":"freefire"}`)
req, _ := http.NewRequest("POST", BASE+"/v1/jobs/instant-redeem", payload)
req.Header.Set("X-API-Key", apiKey)
req.Header.Set("Content-Type", "application/json")

POST /v1/jobs/stock-redeem

Redeems cards from your own stored stock, chosen by pack size. This is the route for selling a package ("1 080 diamonds") without your front end ever handling voucher codes.

Bodyplayer_id · picks object mapping pack size → how many cards · game freefire | pubg | ludo | ludogold | jawaker. Pack keys must belong to that game — see Games.
Free Fire packs100, 210, 530, 1080, 2200. Unknown sizes and non-positive counts are ignored.
ReservationCards are reserved before the redeem, so a parallel job cannot take the same card. If nothing can be reserved, the job fails with no_codes.
Quota1 request per reserved card.
Release rulesA card refused for a transient reason returns to stock; a used or invalid card is marked failed and never picked again.
Errors at POST400 INVALID_PLAYER_ID / MISSING_PICKS / GAME_REDEEM_UNAVAILABLE · 503 SERVICE_DRAINING

Request — two small packs and one big one

{ "player_id": "123456789", "picks": { "100": 2, "1080": 1 }, "game": "freefire" }

Result — three cards, one refused

{ "status": "done", "type": "stock_redeem", "result": { "game": "freefire", "player_id": "123456789", "success": false, "ok_count": 2, "failed_count": 1, "credited": 1298, "point_name": "Diamonds", "history_id": "67ff1a2b3c4d5e6f70819271", "results": [ { "success": true, "code": "4233939008625064", "total": 110, "display_id": "FF2409091234567" }, { "success": true, "code": "4233939008625067", "total": 1188, "display_id": "FF2409091234570" }, { "success": false, "code": "4233939008625068", "err_code": "NETWORK", "outcome": "network" } ] } }

The NETWORK card above is back in stock — check /v1/stock/summary and retry the sale.

POST /v1/jobs/stock-redeem
curl -sS -X POST "${BASE}/v1/jobs/stock-redeem" \
  -H "X-API-Key: pk_YOUR_KEY_HERE" \
  -H "Content-Type: application/json" \
  -d '{"player_id":"123456789","picks":{"100":2,"1080":1},"game":"freefire"}'
job = requests.post(
    f"{BASE}/v1/jobs/stock-redeem",
    headers=HEAD,
    json={"player_id": "123456789", "picks": {"100": 2, "1080": 1}, "game": "freefire"},
    timeout=30,
).json()
const job = await (await fetch(`${BASE}/v1/jobs/stock-redeem`, {
  method: "POST",
  headers: HEAD,
  body: JSON.stringify({ player_id: "123456789", picks: { "100": 2, "1080": 1 }, game: "freefire" }),
})).json();
<?php
$body = json_encode(["player_id" => "123456789", "picks" => ["100" => 2, "1080" => 1], "game" => "freefire"]);
$ctx = stream_context_create(["http" => [
  "method" => "POST",
  "header" => "X-API-Key: pk_YOUR_KEY_HERE\r\nContent-Type: application/json\r\n",
  "content" => $body,
]]);
echo file_get_contents("__ORIGIN__/v1/jobs/stock-redeem", false, $ctx);
payload := strings.NewReader(`{"player_id":"123456789","picks":{"100":2,"1080":1},"game":"freefire"}`)
req, _ := http.NewRequest("POST", BASE+"/v1/jobs/stock-redeem", payload)
req.Header.Set("X-API-Key", apiKey)
req.Header.Set("Content-Type", "application/json")

POST /v1/jobs/code-check

The batch form of /v1/codes/check: up to 200 cards read down one shop session, which takes minutes rather than seconds — hence a job. Nothing is redeemed and no card is consumed, whatever the verdicts turn out to be. This is the route for auditing a supplier file or your own back stock.

Bodycodes 1–200 · game freefire or pubg · with_value default false. Yalla Ludo and Jawaker are refused.
DuplicatesCollapsed before the shop is asked, so a code appears once in the result however often you sent it.
Quota1 request per card read, the same as a redeem, charged when the worker runs.
with_valueOff by default here: it doubles the shop calls. Each card is quoted on its own — a batch may mix tiers.
Telegram alertNone — nothing was redeemed, so there is nothing to tell the buyer.
Errors at POST400 INVALID_CODE / TOO_MANY_CODES / GAME_REDEEM_UNAVAILABLE · 503 SERVICE_DRAINING

Result — read it back with GET /v1/jobs/{job_id}?wait=25

{ "id": "67ff1a2b3c4d5e6f70819202", "type": "code_check", "status": "done", "result": { "game": "freefire", "checked": 3, "counts": { "valid": 2, "used": 1 }, "quota_requests_charged": 0.3, "results": [ { "code": "7397062240255931", "status": "valid", "redeemable": true, "error": null }, { "code": "0305768925042495", "status": "valid", "redeemable": true, "error": null }, { "code": "5539151276341494", "status": "used", "redeemable": false, "error": "error_used_card" } ] } }
POST /v1/jobs/code-check
curl -sS -X POST "${BASE}/v1/jobs/code-check" \
  -H "X-API-Key: pk_YOUR_KEY_HERE" -H "Content-Type: application/json" \
  -d '{"codes":["7397062240255931","0305768925042495","5539151276341494"]}'
job = requests.post(
    f"{BASE}/v1/jobs/code-check", headers=HEAD, json={"codes": batch}, timeout=30
).json()
done = requests.get(f"{BASE}/v1/jobs/{job['job_id']}?wait=25", headers=HEAD, timeout=40).json()
unused = [r["code"] for r in done["result"]["results"] if r["redeemable"]]
const { job_id } = await (await fetch(`${BASE}/v1/jobs/code-check`, {
  method: "POST",
  headers: { ...HEAD, "Content-Type": "application/json" },
  body: JSON.stringify({ codes: batch }),
})).json();
const done = await (await fetch(`${BASE}/v1/jobs/${job_id}?wait=25`, { headers: HEAD })).json();
<?php
$body = json_encode(["codes" => $batch]);
$ctx = stream_context_create(["http" => ["method" => "POST", "content" => $body,
  "header" => "X-API-Key: pk_YOUR_KEY_HERE\r\nContent-Type: application/json\r\n", "ignore_errors" => true]]);
echo file_get_contents("__ORIGIN__/v1/jobs/code-check", false, $ctx);
payload := strings.NewReader(`{"codes":["7397062240255931","5539151276341494"]}`)
req, _ := http.NewRequest("POST", BASE+"/v1/jobs/code-check", payload)
req.Header.Set("X-API-Key", apiKey)
req.Header.Set("Content-Type", "application/json")

GET /v1/jobs/{job_id}

Reads one job you own. With ?wait= the server holds the connection until the job reaches a terminal state, which is both faster and cheaper than a polling loop.

Pathjob_id — the id returned by a create call.
Querywait seconds, 0–60 (default 0 = answer immediately).
Terminal statesdone (worker finished — read the rows) · failed (read error).
Errors404 unknown id, or an id owned by another account.
Quotafree — poll as much as the rate limit allows.

A full done example with a mixed batch is in the response catalog.

GET /v1/jobs

Your recent jobs, newest first — useful to rebuild state after your own process restarted and lost the ids.

Querylimit 1–100 (default 20) · offset ≥ 0
Responsejobs[] in the same sanitised shape as the single-job route, plus limit, offset, total.
Quotafree
{ "jobs": [ { "id": "67ff1a2b3c4d5e6f70819200", "type": "instant_redeem", "status": "done", "error": null, "created_at_stamp": 1789123840, "finished_at_stamp": 1789123864 } ], "limit": 20, "offset": 0, "total": 1 }

POST /v1/order/screenshot

Renders the same order receipt the bot sends — the shop-style card with the order number, the credited amount and the time — from data already stored on the server. The response body is raw PNG bytes, not JSON. Hand it to the customer as proof of delivery.

AliasPOST /api/v1/order/screenshot behaves identically.
BodyExactly one of job_id (an API job you own) or history_id (any redeem of yours, including from Telegram) · result_index integer ≥ 0 (default 0) · lang "en" or "ar"
result_indexWith history_id it indexes the successful rows of that batch. With job_id it indexes result.results[] as returned, and a failed row is rejected.
Success200 · Content-Type: image/png · binary body.
Errors400 MISSING_JOB_OR_HISTORY_ID / NO_ORDER_DETAILS / JOB_NOT_SUCCESSFUL · 404 JOB_NOT_FOUND / HISTORY_NOT_FOUND · 409 JOB_NOT_READY · 429 SCREENSHOT_RATE_LIMIT · 500 ORDER_SCREENSHOT_FAILED
Time zoneThe time on the card follows the zone chosen in the bot (My account → Time zone). Accounts that never picked one get the shop's own region.
Quotafree, but capped at 15 renders per minute per key.
POST /v1/order/screenshot → receipt.png
curl -sS -X POST "${BASE}/v1/order/screenshot" \
  -H "X-API-Key: pk_YOUR_KEY_HERE" \
  -H "Content-Type: application/json" \
  -H "Accept: image/png" \
  -d '{"history_id":"67ff1a2b3c4d5e6f70819270","result_index":0,"lang":"en"}' \
  -o receipt.png
import pathlib
import requests

r = requests.post(
    f"{BASE}/v1/order/screenshot",
    headers={"X-API-Key": "pk_YOUR_KEY_HERE", "Accept": "image/png"},
    json={"history_id": "67ff1a2b3c4d5e6f70819270", "result_index": 0, "lang": "en"},
    timeout=60,
)
if r.status_code == 200 and r.headers.get("Content-Type", "").startswith("image/png"):
    pathlib.Path("receipt.png").write_bytes(r.content)
    print("saved", len(r.content), "bytes")
else:
    print(r.status_code, r.text)
const r = await fetch(`${BASE}/v1/order/screenshot`, {
  method: "POST",
  headers: { ...HEAD, Accept: "image/png" },
  body: JSON.stringify({ history_id: "67ff1a2b3c4d5e6f70819270", lang: "en" }),
});
if (!r.ok) console.log(r.status, await r.text());
else console.log("png bytes", (await r.arrayBuffer()).byteLength);
<?php
$body = json_encode(["history_id" => "67ff1a2b3c4d5e6f70819270", "lang" => "en"]);
$ctx = stream_context_create(["http" => [
  "method" => "POST",
  "header" => "X-API-Key: pk_YOUR_KEY_HERE\r\nContent-Type: application/json\r\nAccept: image/png\r\n",
  "content" => $body,
]]);
file_put_contents("receipt.png", file_get_contents("__ORIGIN__/v1/order/screenshot", false, $ctx));
payload := strings.NewReader(`{"history_id":"67ff1a2b3c4d5e6f70819270","lang":"en"}`)
req, _ := http.NewRequest("POST", BASE+"/v1/order/screenshot", payload)
req.Header.Set("X-API-Key", apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "image/png")
resp, err := http.DefaultClient.Do(req)
// defer resp.Body.Close(); io.Copy(outFile, resp.Body)

Only successful redeem rows have a receipt. A failed card has no order number, so it answers 400 NO_ORDER_DETAILS.

POST /v1/subscription/purchase · purchase-api-addon

Buy from the wallet balance without opening Telegram — handy for automation that watches its own allowance.

/v1/subscription/purchaseBody {"plan_key":"p2","games":["freefire","pubg"]}. Raises the daily allowance for 30 days, for the games picked — their daily limit is shared. One game is the plan's price, two ×1.3, all ×1.5. games omitted or ["all"] is every game, including games added later. Names: freefire, pubg, ludo (Diamonds and Gold), jawaker. Buying a plan you already hold for the same games extends it.
/v1/subscription/purchase-api-addonNo body. Adds 30 days of HTTP access (15 USDT) on top of an active plan.
Errors400 INSUFFICIENT_BALANCE (top the wallet up in the bot) · NEED_PLAN (add-on without a plan) · UNKNOWN_GAME · unknown plan_key.
Response{"ok": true, "result": { … }} with the resulting plan and expiry.
POST /v1/subscription/purchase
curl -sS -X POST "${BASE}/v1/subscription/purchase" \
  -H "X-API-Key: pk_YOUR_KEY_HERE" \
  -H "Content-Type: application/json" \
  -d '{"plan_key":"p2"}'

curl -sS -X POST "${BASE}/v1/subscription/purchase-api-addon" \
  -H "X-API-Key: pk_YOUR_KEY_HERE"
print(requests.post(f"{BASE}/v1/subscription/purchase", headers=HEAD, json={"plan_key": "p2"}, timeout=30).json())
print(requests.post(f"{BASE}/v1/subscription/purchase-api-addon", headers=HEAD, timeout=30).json())
await fetch(`${BASE}/v1/subscription/purchase`, {
  method: "POST",
  headers: HEAD,
  body: JSON.stringify({ plan_key: "p2" }),
});
<?php
$ctx = stream_context_create(["http" => [
  "method" => "POST",
  "header" => "X-API-Key: pk_YOUR_KEY_HERE\r\nContent-Type: application/json\r\n",
  "content" => json_encode(["plan_key" => "p2"]),
]]);
echo file_get_contents("__ORIGIN__/v1/subscription/purchase", false, $ctx);
payload := strings.NewReader(`{"plan_key":"p2"}`)
req, _ := http.NewRequest("POST", BASE+"/v1/subscription/purchase", payload)
req.Header.Set("X-API-Key", apiKey)
req.Header.Set("Content-Type", "application/json")

Games & packs

One subscription, one key, one set of routes. Set game and follow that game's code, player-id and pack rules. The customer never talks to another bot.

slugCodePlayer idLookupCheckRedeem
freefire 16 digits 6–12 digits ✅ ✅ ✅ diamonds
pubg 18 letters/digits 8–11 digits, starts with 5 ✅ ✅ ✅ UC
ludo 12 letters/digits 6–16 digits ✅ inside redeem only ✅ Diamonds + tier
ludogold 12 letters/digits 6–16 digits ✅ inside redeem only ✅ Gold + tier
jawaker 10 letters/digits (voucher) or 12 (Mintroute pin) 6–12 digits ✅ inside redeem only ✅ tokens

Spaces, dashes and case are stripped from every code. A batch is one player and one game. Do not mix shops in one job.

Free Fire

slugfreefire (default when omitted)
CodeExactly 16 digits after normalisation. Spaces and dashes are stripped.
Player id6–12 digits. Most countries are served.
Packs100 · 210 · 530 · 1,080 · 2,200 diamonds. These are the keys of picks on stock-redeem.
CheckPOST /v1/codes/check and POST /v1/jobs/code-check. A good card stays good.
ReceiptShop order confirmation PNG via POST /v1/order/screenshot.
RegionUnited States, Argentina, Chile and Russia: lookup answers 422 UNSUPPORTED_REGION. A card the store will not accept is REGION_MISMATCH and is not spent.
Free Fire — lookup, check, redeem
curl -sS "${BASE}/v1/player/lookup?player_id=123456789&game=freefire" \
  -H "X-API-Key: pk_YOUR_KEY_HERE"

curl -sS -X POST "${BASE}/v1/codes/check" \
  -H "X-API-Key: pk_YOUR_KEY_HERE" -H "Content-Type: application/json" \
  -d '{"codes":["4233939008625064"],"game":"freefire","with_value":true}'

curl -sS -X POST "${BASE}/v1/jobs/instant-redeem" \
  -H "X-API-Key: pk_YOUR_KEY_HERE" -H "Content-Type: application/json" \
  -d '{"player_id":"123456789","codes":["4233939008625064"],"game":"freefire"}'
requests.get(f"{BASE}/v1/player/lookup", headers=HEAD,
             params={"player_id": "123456789", "game": "freefire"}, timeout=60)
requests.post(f"{BASE}/v1/codes/check", headers=HEAD,
              json={"codes": ["4233939008625064"], "game": "freefire", "with_value": True}, timeout=60)
requests.post(f"{BASE}/v1/jobs/instant-redeem", headers=HEAD,
              json={"player_id": "123456789", "codes": ["4233939008625064"], "game": "freefire"}, timeout=30)
await fetch(`${BASE}/v1/player/lookup?player_id=123456789&game=freefire`, { headers: HEAD });
await fetch(`${BASE}/v1/jobs/instant-redeem`, {
  method: "POST", headers: HEAD,
  body: JSON.stringify({ player_id: "123456789", codes: ["4233939008625064"], game: "freefire" }),
});
<?php
echo file_get_contents("__ORIGIN__/v1/player/lookup?player_id=123456789&game=freefire", false,
  stream_context_create(["http" => ["header" => "X-API-Key: pk_YOUR_KEY_HERE\r\n"]]));
req, _ := http.NewRequest("GET", BASE+"/v1/player/lookup?player_id=123456789&game=freefire", nil)
req.Header.Set("X-API-Key", apiKey)

PUBG Mobile

UC vouchers redeemed onto a character id. Same Telegram and API surface as Free Fire: lookup, standalone check, instant, stock, history and the shop receipt. The work is done on this server through an internal engine — your customer never sees another bot.

slugpubg
CodeExactly 18 letters or digits. Case does not matter.
Player id8–11 digits, always starting with 5.
Packs60 · 325 · 660 · 1,800 · 3,850 · 8,100 UC. These are the keys of picks.
CheckSame routes as Free Fire. Statuses include valid, used, invalid, expired.
ReceiptThe shop's own ORDER DETAILS card, returned as PNG from /v1/order/screenshot.
Refused accountA banned or restricted character comes back as 422 UNSUPPORTED_REGION on lookup and as UNSUPPORTED_REGION rows on redeem. Cards are not spent.
{ "player_id": "5123456789", "codes": ["AB12CD34EF56GH78IJ"], "game": "pubg" }
PUBG — lookup, check, redeem, stock
curl -sS "${BASE}/v1/player/lookup?player_id=5123456789&game=pubg" \
  -H "X-API-Key: pk_YOUR_KEY_HERE"

curl -sS -X POST "${BASE}/v1/codes/check" \
  -H "X-API-Key: pk_YOUR_KEY_HERE" -H "Content-Type: application/json" \
  -d '{"codes":["AB12CD34EF56GH78IJ"],"game":"pubg","with_value":true}'

curl -sS -X POST "${BASE}/v1/jobs/instant-redeem" \
  -H "X-API-Key: pk_YOUR_KEY_HERE" -H "Content-Type: application/json" \
  -d '{"player_id":"5123456789","codes":["AB12CD34EF56GH78IJ"],"game":"pubg"}'

curl -sS -X POST "${BASE}/v1/jobs/stock-redeem" \
  -H "X-API-Key: pk_YOUR_KEY_HERE" -H "Content-Type: application/json" \
  -d '{"player_id":"5123456789","picks":{"60":2,"325":1},"game":"pubg"}'
requests.get(f"{BASE}/v1/player/lookup", headers=HEAD,
             params={"player_id": "5123456789", "game": "pubg"}, timeout=60)
requests.post(f"{BASE}/v1/codes/check", headers=HEAD,
              json={"codes": ["AB12CD34EF56GH78IJ"], "game": "pubg"}, timeout=60)
requests.post(f"{BASE}/v1/jobs/instant-redeem", headers=HEAD,
              json={"player_id": "5123456789", "codes": ["AB12CD34EF56GH78IJ"], "game": "pubg"}, timeout=30)
requests.post(f"{BASE}/v1/jobs/stock-redeem", headers=HEAD,
              json={"player_id": "5123456789", "picks": {"60": 2, "325": 1}, "game": "pubg"}, timeout=30)
await fetch(`${BASE}/v1/player/lookup?player_id=5123456789&game=pubg`, { headers: HEAD });
await fetch(`${BASE}/v1/jobs/instant-redeem`, {
  method: "POST", headers: HEAD,
  body: JSON.stringify({ player_id: "5123456789", codes: ["AB12CD34EF56GH78IJ"], game: "pubg" }),
});
<?php
$body = json_encode(["player_id" => "5123456789", "codes" => ["AB12CD34EF56GH78IJ"], "game" => "pubg"]);
$ctx = stream_context_create(["http" => ["method" => "POST",
  "header" => "X-API-Key: pk_YOUR_KEY_HERE\r\nContent-Type: application/json\r\n", "content" => $body]]);
echo file_get_contents("__ORIGIN__/v1/jobs/instant-redeem", false, $ctx);
payload := strings.NewReader(`{"player_id":"5123456789","codes":["AB12CD34EF56GH78IJ"],"game":"pubg"}`)
req, _ := http.NewRequest("POST", BASE+"/v1/jobs/instant-redeem", payload)
req.Header.Set("X-API-Key", apiKey)
req.Header.Set("Content-Type", "application/json")

Yalla Ludo

Diamonds and Gold are two stores that share codes and player ids. A code does not say which pack it is — you must send the pack. The Telegram bot asks for it after the id; the API takes it as tier. Checking is internal to the redeem: the card is read against the opposite product so a mismatch never spends it. There is no /v1/codes/check for Ludo.

slugsludo (Diamonds) · ludogold (Gold)
CodeExactly 12 letters or digits.
Player id6–16 digits.
Diamonds packs830 · 2,320 · 5,150 · 13,580 · 27,640 · 55,800 · 168,860 · 283,460 — USD 2 · 5 · 10 · 25 · 50 · 100 · 300 · 500
Gold packs68,500 · 223,700 · 1,463,320 · 3,666,470 · 9,973,990 · 25,236,460 · 76,000,860 · 126,910,990
tierRequired on instant-redeem. An in-game quantity (830, 68500, …) or a Diamonds USD price (2, 5, …). A bare USD price always means Diamonds. Gold must be its quantity. Sending a Gold quantity with game=ludo is accepted — the job is switched to ludogold.
Stockpicks keys are the quantities above, under the matching slug. No tier field — the pack is the key.
CheckNone as a route. A dead or mismatched card comes back as INVALID_CODE without being spent.
ReceiptYalla Pay result page, rendered as PNG. Language is Arabic for now.
{ "player_id": "123456789", "codes": ["H216355GQQRB"], "game": "ludo", "tier": "2" }
{ "player_id": "123456789", "codes": ["H42LR46R1QJ8"], "game": "ludogold", "tier": "68500" }
Yalla Ludo — lookup, Diamonds $2, Gold stock
curl -sS "${BASE}/v1/player/lookup?player_id=123456789&game=ludo" \
  -H "X-API-Key: pk_YOUR_KEY_HERE"

curl -sS -X POST "${BASE}/v1/jobs/instant-redeem" \
  -H "X-API-Key: pk_YOUR_KEY_HERE" -H "Content-Type: application/json" \
  -d '{"player_id":"123456789","codes":["H216355GQQRB"],"game":"ludo","tier":"2"}'

curl -sS -X POST "${BASE}/v1/jobs/instant-redeem" \
  -H "X-API-Key: pk_YOUR_KEY_HERE" -H "Content-Type: application/json" \
  -d '{"player_id":"123456789","codes":["H42LR46R1QJ8"],"game":"ludogold","tier":"68500"}'

curl -sS -X POST "${BASE}/v1/jobs/stock-redeem" \
  -H "X-API-Key: pk_YOUR_KEY_HERE" -H "Content-Type: application/json" \
  -d '{"player_id":"123456789","picks":{"830":1},"game":"ludo"}'
requests.get(f"{BASE}/v1/player/lookup", headers=HEAD,
             params={"player_id": "123456789", "game": "ludo"}, timeout=60)
requests.post(f"{BASE}/v1/jobs/instant-redeem", headers=HEAD,
              json={"player_id": "123456789", "codes": ["H216355GQQRB"], "game": "ludo", "tier": "2"}, timeout=30)
requests.post(f"{BASE}/v1/jobs/instant-redeem", headers=HEAD,
              json={"player_id": "123456789", "codes": ["H42LR46R1QJ8"], "game": "ludogold", "tier": "68500"}, timeout=30)
requests.post(f"{BASE}/v1/jobs/stock-redeem", headers=HEAD,
              json={"player_id": "123456789", "picks": {"830": 1}, "game": "ludo"}, timeout=30)
await fetch(`${BASE}/v1/jobs/instant-redeem`, {
  method: "POST", headers: HEAD,
  body: JSON.stringify({ player_id: "123456789", codes: ["H216355GQQRB"], game: "ludo", tier: "2" }),
});
<?php
$body = json_encode(["player_id" => "123456789", "codes" => ["H216355GQQRB"],
  "game" => "ludo", "tier" => "2"]);
$ctx = stream_context_create(["http" => ["method" => "POST",
  "header" => "X-API-Key: pk_YOUR_KEY_HERE\r\nContent-Type: application/json\r\n", "content" => $body]]);
echo file_get_contents("__ORIGIN__/v1/jobs/instant-redeem", false, $ctx);
payload := strings.NewReader(`{"player_id":"123456789","codes":["H216355GQQRB"],"game":"ludo","tier":"2"}`)
req, _ := http.NewRequest("POST", BASE+"/v1/jobs/instant-redeem", payload)
req.Header.Set("X-API-Key", apiKey)
req.Header.Set("Content-Type", "application/json")

Jawaker

Jawaker tokens redeemed onto a player number: lookup, instant, stock, history and the receipt. No tier is needed — the card carries its own amount. There is no standalone /v1/codes/check for Jawaker; game=jawaker there answers GAME_REDEEM_UNAVAILABLE.

slugjawaker
CodeTwo kinds, told apart by length. 10 letters or digits is a Jawaker voucher. 12 letters or digits is a Mintroute pin (sold by resellers). Case, spaces and dashes do not matter.
Player idThe Jawaker player number: 6–12 digits. Lookup returns the login as nickname and the account level.
Packs4,250 · 32,500 · 70,000 · 150,000 · 230,000 · 400,000 · 525,000 · 805,000 · 825,000 tokens. These are the keys of picks.
CheckNone as a route. Inside the redeem a 10-character voucher is read first, so a dead one comes back INVALID_CODE without being sent. Jawaker gives the same answer for a card already used and a card that never existed.
RedeemThe player is looked up first, so a wrong number fails as PLAYER_NOT_FOUND before any card is sent. A dead voucher is INVALID_CODE. A voucher is read before it is spent, and that read confirms the credited amount.
Lost answerIf the connection drops after sending, a 10-character voucher is read again to learn whether it went through. A Mintroute pin is never re-sent: its row comes back as err_code: "NETWORK" and is not billed. Check the player's balance before you retry it.
ReceiptJawaker's own “confirm voucher” and “done” dialogs with the login and token amount, returned as PNG from /v1/order/screenshot.
{ "player_id": "1850890235", "codes": ["9W726P9AE7"], "game": "jawaker" }
Jawaker — lookup, redeem, stock
curl -sS "${BASE}/v1/player/lookup?player_id=1850890235&game=jawaker" \
  -H "X-API-Key: pk_YOUR_KEY_HERE"

curl -sS -X POST "${BASE}/v1/jobs/instant-redeem" \
  -H "X-API-Key: pk_YOUR_KEY_HERE" -H "Content-Type: application/json" \
  -d '{"player_id":"1850890235","codes":["9W726P9AE7"],"game":"jawaker"}'

curl -sS -X POST "${BASE}/v1/jobs/stock-redeem" \
  -H "X-API-Key: pk_YOUR_KEY_HERE" -H "Content-Type: application/json" \
  -d '{"player_id":"1850890235","picks":{"4250":2},"game":"jawaker"}'
requests.get(f"{BASE}/v1/player/lookup", headers=HEAD,
             params={"player_id": "1850890235", "game": "jawaker"}, timeout=60)
requests.post(f"{BASE}/v1/jobs/instant-redeem", headers=HEAD,
              json={"player_id": "1850890235", "codes": ["9W726P9AE7"], "game": "jawaker"}, timeout=30)
requests.post(f"{BASE}/v1/jobs/stock-redeem", headers=HEAD,
              json={"player_id": "1850890235", "picks": {"4250": 2}, "game": "jawaker"}, timeout=30)
await fetch(`${BASE}/v1/player/lookup?player_id=1850890235&game=jawaker`, { headers: HEAD });
await fetch(`${BASE}/v1/jobs/instant-redeem`, {
  method: "POST", headers: HEAD,
  body: JSON.stringify({ player_id: "1850890235", codes: ["9W726P9AE7"], game: "jawaker" }),
});
<?php
$body = json_encode(["player_id" => "1850890235", "codes" => ["9W726P9AE7"], "game" => "jawaker"]);
$ctx = stream_context_create(["http" => ["method" => "POST",
  "header" => "X-API-Key: pk_YOUR_KEY_HERE\r\nContent-Type: application/json\r\n", "content" => $body]]);
echo file_get_contents("__ORIGIN__/v1/jobs/instant-redeem", false, $ctx);
payload := strings.NewReader(`{"player_id":"1850890235","codes":["9W726P9AE7"],"game":"jawaker"}`)
req, _ := http.NewRequest("POST", BASE+"/v1/jobs/instant-redeem", payload)
req.Header.Set("X-API-Key", apiKey)
req.Header.Set("Content-Type", "application/json")

Telegram alerts for API jobs

The bot can message you when an API redeem finishes, which is the fastest way to watch an integration in production without building your own dashboard. In @ZadTopBot: Subscription → HTTP API → Settings.

Success alertsOne message per finished job with at least one credited card, including the amount and a button that renders the receipt.
Failure alertsSent when a job fails outright (quota, validation, interruption) or when every card in it was refused.
IndependentAlerts are a convenience, not a delivery guarantee: your integration should still read the job result.

Full integration recipe

A complete order: confirm the player, redeem, read every row, save the receipt, log what to retry.

One order, end to end
# 1) confirm the player
curl -sS "${BASE}/v1/player/lookup?player_id=123456789" -H "X-API-Key: $KEY"

# 2) queue the redeem
JOB=$(curl -sS -X POST "${BASE}/v1/jobs/instant-redeem" -H "X-API-Key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{"player_id":"123456789","codes":["4233939008625064"]}' \
  | sed -n 's/.*"job_id":"\([^"]*\)".*/\1/p')

# 3) wait for the outcome
DOC=$(curl -sS "${BASE}/v1/jobs/$JOB?wait=25" -H "X-API-Key: $KEY")
echo "$DOC"

# 4) receipt for the first successful row
HID=$(echo "$DOC" | sed -n 's/.*"history_id":"\([^"]*\)".*/\1/p')
curl -sS -X POST "${BASE}/v1/order/screenshot" -H "X-API-Key: $KEY" \
  -H "Content-Type: application/json" -d "{\"history_id\":\"$HID\"}" -o receipt.png
import pathlib
import time

import requests

BASE = "__ORIGIN__"
KEY = "pk_YOUR_KEY_HERE"
HEAD = {"X-API-Key": KEY, "Content-Type": "application/json"}

RETRYABLE = {"NETWORK", "SESSION_EXPIRED", "RATE_LIMITED", "CAPTCHA", "CAPTCHA_FAILED", "SHOP_BLOCKED"}


def sell(player_id: str, codes: list[str]) -> dict:
    look = requests.get(f"{BASE}/v1/player/lookup", headers=HEAD, params={"player_id": player_id}, timeout=60)
    if look.status_code == 404:
        raise ValueError("player id does not exist")
    nickname = look.json().get("player_name", "") if look.ok else ""

    job = requests.post(
        f"{BASE}/v1/jobs/instant-redeem",
        headers=HEAD,
        json={"player_id": player_id, "codes": codes, "game": "freefire"},
        timeout=30,
    ).json()

    end = time.time() + 300
    doc = {}
    while time.time() < end:
        doc = requests.get(f"{BASE}/v1/jobs/{job['job_id']}?wait=25", headers=HEAD, timeout=40).json()
        if doc.get("status") in ("done", "failed"):
            break

    if doc.get("status") != "done":
        raise RuntimeError(doc.get("error") or "job did not finish")

    res = doc["result"]
    retry, dead = [], []
    for row in res["results"]:
        if row["success"]:
            continue
        (retry if row.get("err_code") in RETRYABLE else dead).append(row["code"])

    if res["ok_count"]:
        png = requests.post(
            f"{BASE}/v1/order/screenshot",
            headers=HEAD,
            json={"history_id": res["history_id"], "result_index": 0, "lang": "en"},
            timeout=60,
        )
        if png.status_code == 200:
            pathlib.Path(f"receipt_{res['history_id']}.png").write_bytes(png.content)

    return {"nickname": nickname, "credited": res["credited"], "retry": retry, "dead": dead}


print(sell("123456789", ["4233939008625064"]))
const BASE = "__ORIGIN__";
const HEAD = { "X-API-Key": "pk_YOUR_KEY_HERE", "Content-Type": "application/json" };
const RETRYABLE = new Set(["NETWORK", "SESSION_EXPIRED", "RATE_LIMITED", "CAPTCHA", "CAPTCHA_FAILED", "SHOP_BLOCKED"]);

export async function sell(playerId, codes) {
  const look = await fetch(`${BASE}/v1/player/lookup?player_id=${playerId}`, { headers: HEAD });
  if (look.status === 404) throw new Error("player id does not exist");

  const job = await (await fetch(`${BASE}/v1/jobs/instant-redeem`, {
    method: "POST",
    headers: HEAD,
    body: JSON.stringify({ player_id: playerId, codes, game: "freefire" }),
  })).json();

  let doc;
  const end = Date.now() + 300000;
  while (Date.now() < end) {
    doc = await (await fetch(`${BASE}/v1/jobs/${job.job_id}?wait=25`, { headers: HEAD })).json();
    if (doc.status === "done" || doc.status === "failed") break;
  }
  if (doc.status !== "done") throw new Error(doc.error || "job did not finish");

  const retry = [], dead = [];
  for (const row of doc.result.results) {
    if (row.success) continue;
    (RETRYABLE.has(row.err_code) ? retry : dead).push(row.code);
  }
  return { credited: doc.result.credited, historyId: doc.result.history_id, retry, dead };
}
<?php
// 1) lookup  2) POST instant-redeem  3) poll /v1/jobs/{id}?wait=25
// 4) for each result row: success -> deliver, err_code in
//    [NETWORK, SESSION_EXPIRED, RATE_LIMITED, CAPTCHA, CAPTCHA_FAILED, SHOP_BLOCKED] -> retry later,
//    otherwise -> retire the code
// 5) POST /v1/order/screenshot with result.history_id to store the receipt PNG
// 1) GET  /v1/player/lookup?player_id=…
// 2) POST /v1/jobs/instant-redeem  -> job_id
// 3) GET  /v1/jobs/{job_id}?wait=25 until status is done or failed
// 4) range over result.results: success, else switch on err_code (retry vs retire)
// 5) POST /v1/order/screenshot with result.history_id -> receipt.png
Checklist for production: keep the key server-side · store job_id next to your own order id · never auto-resend a code after interrupted:process_restart without checking history · retry 503 SERVICE_DRAINING and 429 with backoff · reconcile daily through /v1/history.

Swagger — interactive testing

Authorise with X-API-Key, then call any endpoint straight from the browser. The embedded frame below is convenient, but a full tab is easier to read.

Open Swagger in new tab Same origin · full window

OpenAPI 3 · embedded preview ReDoc openapi.json