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.
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
POSTanswers immediately with ajob_idand the work runs in a server-side queue. ReadGET /v1/jobs/{job_id}?wait=25to 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.
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.
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.
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
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
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
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 codes | Free 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 id | Digits only, 6–12 characters for Free Fire. Always sent and returned as a string. |
| Format | Requests and responses are application/json, UTF-8. The one exception is POST /v1/order/screenshot, which returns raw PNG bytes (image/png) on success. |
| Timestamps | Fields ending in _stamp are Unix seconds (UTC). time_created is a human-readable server string. Daily allowance resets on the UTC calendar day. |
| IDs | job_id, history_id and history id are MongoDB ObjectId strings (24 hex characters). |
| Ownership | Every 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. |
| Idempotency | There 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. |
| Versioning | The /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. |
| OpenAPI | Machine-readable schema: /openapi.json · interactive UI: /swagger · ReDoc: /redoc. |
Authentication
Every route below except GET /health requires one header:
| Getting a key | In @ZadTopBot: Subscription → HTTP API → API key → New key. The key is shown once; creating a new one revokes the old one immediately. |
|---|---|
| Eligibility | An 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 key | 401 — Missing X-API-Key header or Invalid or revoked API key. |
| One key, one account | The key maps to the Telegram account that owns the wallet, the stock and the history. Requests appear in that account's history and alerts. |
| Security | Keep 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 trail | Calls 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 order | Free-trial requests first, then the combined daily allowance of active plans, then an admin API trial if one was granted. |
|---|---|
| Daily reset | The daily counter (usage_today) resets at the UTC day boundary. |
| Stacking | Several active plans add up: daily_limit_combined is their sum, so a cap above the top tier's 10 000/day is possible. |
| instant-redeem | 1 request per code in codes (max 10 per job). |
| stock-redeem | 1 request per card actually reserved from your stock. |
| What decides a charge | Whose 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/lookup | 0.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-check | 1 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 automatically | A 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 left | The 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. |
| Fractions | Accounting is fractional, so lookups and redeems mix cleanly (e.g. 4 lookups = 1 request). |
| Next to the bot | One 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_key | Requests / UTC day | Price |
|---|---|---|
p1 | 25 | 5 USDT / 30 days |
p2 | 200 | 10 USDT / 30 days |
p3 | 1 000 | 25 USDT / 30 days |
p4 | 2 500 | 50 USDT / 30 days |
p5 | 5 000 | 85 USDT / 30 days |
p6 | 10 000 | 135 USDT / 30 days |
| API add-on | unlocks HTTP access | 15 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 lanes | Up 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 workers | The 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. |
| Screenshot | 15 renders per minute per key → 429 SCREENSHOT_RATE_LIMIT with Retry-After: 60. |
| Global throttle | An optional per-minute cap on /v1/* and /api/v1/* (429 RATE_LIMIT, Retry-After: 60). Off unless the operator enables it. |
| Polling etiquette | Use ?wait=25 rather than a tight loop. Without wait, poll no faster than once per second. |
| Safe restart | While 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.
| Code | When | What to do |
|---|---|---|
200 | Parsed 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. |
400 | Validation: 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. |
401 | X-API-Key missing, misspelled, or revoked by creating a newer key. | Re-copy the key from the bot. |
403 | No active plan + API add-on (and no admin trial). | Renew in the bot, then retry; eligibility is cached for a few seconds. |
404 | PLAYER_NOT_FOUND, unknown job_id, or HISTORY_NOT_FOUND — including ids that belong to another account. | Verify the id; list jobs or history. |
409 | JOB_NOT_READY — a screenshot was asked for while the job is still pending/running. | Wait for the job, then retry. |
422 | Body did not match the schema (wrong type, missing field, more than 10 codes). | Read the FastAPI validation detail; fix the payload. |
429 | RATE_LIMIT (all lanes busy or global throttle) or SCREENSHOT_RATE_LIMIT. | Honour Retry-After: 60; add backoff. |
500 | ORDER_SCREENSHOT_FAILED or an unexpected server error. | Retry once; include the message when contacting support. |
502 | LOOKUP_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. |
503 | SERVICE_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 layer | Status line + detail — auth, validation, throttles, ownership. |
|---|---|
| Job layer | status and error on GET /v1/jobs/{id} — the queue lifecycle. |
| Outcome layer | result.ok_count, result.failed_count and each result.results[].success / err_code — the business result, per card. |
status: "done" means the worker finished, not that diamonds were credited.
Partial batches are normal: always loop over result.results[].
Structured detail.error codes
| error | HTTP | Routes | Meaning |
|---|---|---|---|
INVALID_PLAYER_ID | 400 | lookup, redeem jobs | Does not match the chosen game (see Games). |
INVALID_CODE | 400 | instant-redeem, codes/check | A code does not match the chosen game after normalisation. |
TOO_MANY_CODES | 400 | codes/check, code-check | More codes than the route takes — limit and sent are in the body. |
UNKNOWN_GAME | 400 | all game routes | game is not freefire, pubg, ludo, ludogold or jawaker. |
MISSING_TIER | 400 | instant-redeem | Yalla Ludo was posted without a resolvable tier (quantity or Diamonds USD price). |
GAME_REDEEM_UNAVAILABLE | 400 | redeem / check | This game cannot be redeemed, or standalone check was asked for Yalla Ludo. |
GAME_LOOKUP_UNAVAILABLE | 400 | player/lookup | Lookup is not offered for this game. |
MISSING_PICKS | 400 | stock-redeem | picks was empty. |
PLAYER_NOT_FOUND | 404 | player/lookup | No account with that id for the chosen game. |
UNSUPPORTED_REGION | 422 | player/lookup | The 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_FAILED | 502 | player/lookup | Upstream failure; no quota charged. |
RATE_LIMIT | 429 | lookup, audited routes | Lanes busy or global throttle. Retry-After: 60. |
QUOTA_EXHAUSTED | 429 | player/lookup, codes/check | Not 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_PLAN | 403 | player/lookup, codes/check, every job route | None 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_LIMIT | 429 | order/screenshot | More than 15 renders in a minute for this key. |
MISSING_JOB_OR_HISTORY_ID | 400 | order/screenshot | Neither job_id nor history_id was sent. |
JOB_NOT_FOUND | 404 | order/screenshot | No such job for this key. |
HISTORY_NOT_FOUND | 404 | order/screenshot | No such history row for this key. |
JOB_NOT_READY | 409 | order/screenshot | Job still pending/running. |
JOB_NOT_SUCCESSFUL | 400 | order/screenshot | Job ended failed. |
NO_ORDER_DETAILS | 400 | order/screenshot | result_index points at no row, or at a failed one. |
ORDER_SCREENSHOT_FAILED | 500 | order/screenshot | Render failed unexpectedly. |
SERVICE_DRAINING | 503 | redeem jobs | Safe restart; nothing queued. |
NEED_PLAN · INSUFFICIENT_BALANCE | 400 | subscription routes | Buy 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.
status | pending → running → done or failed |
|---|---|
result | Present when the worker stored an outcome (normally on done). Sanitised — no sessions, proxies or internal detail. |
error | A short string when status is failed; null otherwise. |
| Timing | created_at_stamp, started_at_stamp, finished_at_stamp (Unix seconds). |
Job error (status failed) | Meaning | Client 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_id | Player id rejected by the worker. | Validate before posting. |
no_codes | No valid code survived normalisation, or stock held none of the requested packs. | Check the codes, or /v1/stock/summary. |
invalid_payload | picks could not be read as pack → quantity. | Send integers keyed by pack size. |
unknown_game · game_redeem_unavailable | Game slug unknown, or this game cannot be redeemed. | Use freefire, pubg, ludo, ludogold or jawaker. |
tier_required | Yalla 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_restart | The 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_type | Internal mismatch (rare). | Contact support with the job_id. |
| anything else | An 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_code | success | Meaning | Recommended handling |
|---|---|---|---|
| null | true | Diamonds credited. The row carries display_id, amount, bonus, total. | Show the receipt; keep display_id as the order number. |
CODE_ALREADY_USED | false | The card was already redeemed — by you earlier, or by whoever had it before. | Retire the code. Never retry; it will never succeed. |
INVALID_CODE | false | The shop does not recognise the card (wrong or expired). | Retire the code; verify how it was typed. |
REGION_MISMATCH | false | The card belongs to another region than the player. | Use it for a player in the matching region. |
UNSUPPORTED_REGION | false | Garena 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_FOUND | false | The 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_LIMITED | false | The shop throttled this attempt. | Retry the same code later with backoff. |
CAPTCHA · CAPTCHA_FAILED | false | A challenge appeared and was not solved. | Retry later; the card was not spent. |
SHOP_BLOCKED | false | The shop refused the route used for this attempt. | Retry later; contact support if it persists. |
NETWORK · SESSION_EXPIRED | false | Transport or session problem before a result was known. | Retry the same code; check history first if you want certainty. |
REDEEM_FAILED | false | The shop refused for a reason with no specific mapping. | Show the neutral message, retry once, then escalate. |
Example — done, with one card refused
Example — failed on quota
GET /health
Liveness probe for load balancers and uptime monitors. No authentication, no quota, no shop contact.
| Response | 200 · {"ok": true} |
|---|---|
| Use case | Uptime checks, TLS verification, deployment smoke test. |
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.
| Query | none |
|---|---|
| Quota | free |
has_api_subscription | false means the add-on lapsed; redeem routes will answer 403 once the eligibility cache expires. |
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_remaining | Free-trial requests left (spent first). |
|---|---|
daily_limit_combined | Sum of the daily allowances of all active plans. |
usage_today | Spent since the last UTC midnight. |
daily_left_subscription | What today's plans still allow. With ?game=pubg, only the plans that cover that game count. |
api_trial_remaining | Admin-granted API trial requests, if any. |
total_requests_left_approx | The practical number to budget against. |
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.
| Query | limit 1–100 (default 20) · offset ≥ 0 · player_id substring · code substring · days 7, 30 or 90 (omit for all time) |
|---|---|
| Filters | player_id and code match on substrings, so the last four digits of a card are enough. They combine with days using AND. |
| Paging | total is the count after filtering — page with offset += limit. |
status | ok or failed. outcome carries the finer engine reason (used_card, invalid_card, network, …). |
history_id | The batch this row belongs to — pass it to order/screenshot to draw the receipt. |
| Quota | free |
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.
| Query | limit 1–200 (default 50) |
|---|---|
| Order | Most recently seen first (last_seen_stamp). |
| Quota | free — cache this instead of repeating a paid lookup for a known player. |
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.
| Query | player_id required · game freefire (default), pubg, ludo, ludogold or jawaker. Id rules follow the game — see Games. |
|---|---|
| Quota | 0.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 effect | The player is stored and appears in /v1/players and in the bot. |
| Errors | 400 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
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.
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.
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.
| Body | codes 1–20 · game freefire or pubg · with_value true to price them too. Yalla Ludo and Jawaker answer GAME_REDEEM_UNAVAILABLE. |
|---|---|
| Quota | 1 request per card the shop answered for, the same as a redeem. A card it never answered about is not charged. |
| Larger batches | Up to 200 cards through POST /v1/jobs/code-check, which is polled like a redeem job. |
| Side effect | none. Nothing is redeemed, no card is consumed, no stock row changes. |
| Errors | 400 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
| status | Meaning | redeemable |
|---|---|---|
valid | The card exists and has not been used. | true |
used | Already redeemed, by anyone. | false |
invalid | No such card — a typo or an invention. | false |
expired · inactive · blocked | The shop's own refusals for a card that exists but cannot be used. | false |
region_mismatch | The card belongs to a different storefront than the one it was read under. | false |
unknown · error | We 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
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.
| Query | none |
|---|---|
| Shape | One object per game slug, each with name, redeemable, by_denomination (pack size → count) and total. |
| Games | One object per slug the account can hold: freefire, pubg, ludo, ludogold, jawaker. All five are redeemable: true. |
| Quota | free |
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.
| Create | POST /v1/jobs/instant-redeem · POST /v1/jobs/stock-redeem |
|---|---|
| Read | GET /v1/jobs/{job_id} (optionally ?wait=) · GET /v1/jobs |
| Billing moment | When the worker starts the batch — a queued job that never runs costs nothing. |
| Ordering | Oldest job first, up to 4 in parallel per account. |
| Typical latency | A single code usually settles in a few seconds; a batch is roughly linear, with a short human-like pause between cards. |
| Restart safety | A 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
Still working
Wait for the result instead of polling
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.
| Body | player_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. |
|---|---|
| Normalisation | Spaces, dashes and case are stripped from each code before validation. |
| Quota | 1 request per code, charged when the worker runs. |
| Errors at POST | 400 INVALID_PLAYER_ID / INVALID_CODE / UNKNOWN_GAME / GAME_REDEEM_UNAVAILABLE · 422 schema · 503 SERVICE_DRAINING |
| Result | ok_count, failed_count, credited, nickname, history_id, and one results[] row per code in the order you sent them. |
/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
Request — three cards for the same player in one 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"}'
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.
| Body | player_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 packs | 100, 210, 530, 1080, 2200. Unknown sizes and non-positive counts are ignored. |
| Reservation | Cards 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. |
| Quota | 1 request per reserved card. |
| Release rules | A card refused for a transient reason returns to stock; a used or invalid card is marked failed and never picked again. |
| Errors at POST | 400 INVALID_PLAYER_ID / MISSING_PICKS / GAME_REDEEM_UNAVAILABLE · 503 SERVICE_DRAINING |
Request — two small packs and one big one
Result — three cards, one refused
The NETWORK card above is back in stock — check /v1/stock/summary and retry the sale.
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.
| Body | codes 1–200 · game freefire or pubg · with_value default false. Yalla Ludo and Jawaker are refused. |
|---|---|
| Duplicates | Collapsed before the shop is asked, so a code appears once in the result however often you sent it. |
| Quota | 1 request per card read, the same as a redeem, charged when the worker runs. |
with_value | Off by default here: it doubles the shop calls. Each card is quoted on its own — a batch may mix tiers. |
| Telegram alert | None — nothing was redeemed, so there is nothing to tell the buyer. |
| Errors at POST | 400 INVALID_CODE / TOO_MANY_CODES / GAME_REDEEM_UNAVAILABLE · 503 SERVICE_DRAINING |
Result — read it back with GET /v1/jobs/{job_id}?wait=25
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.
| Path | job_id — the id returned by a create call. |
|---|---|
| Query | wait seconds, 0–60 (default 0 = answer immediately). |
| Terminal states | done (worker finished — read the rows) · failed (read error). |
| Errors | 404 unknown id, or an id owned by another account. |
| Quota | free — 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.
| Query | limit 1–100 (default 20) · offset ≥ 0 |
|---|---|
| Response | jobs[] in the same sanitised shape as the single-job route, plus limit, offset, total. |
| Quota | free |
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.
| Alias | POST /api/v1/order/screenshot behaves identically. |
|---|---|
| Body | Exactly 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_index | With 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. |
| Success | 200 · Content-Type: image/png · binary body. |
| Errors | 400 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 zone | The 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. |
| Quota | free, but capped at 15 renders per minute per key. |
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/purchase | Body {"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-addon | No body. Adds 30 days of HTTP access (15 USDT) on top of an active plan. |
| Errors | 400 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. |
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.
| slug | Code | Player id | Lookup | Check | Redeem |
|---|---|---|---|---|---|
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
| slug | freefire (default when omitted) |
|---|---|
| Code | Exactly 16 digits after normalisation. Spaces and dashes are stripped. |
| Player id | 6–12 digits. Most countries are served. |
| Packs | 100 · 210 · 530 · 1,080 · 2,200 diamonds. These are the keys of picks on stock-redeem. |
| Check | POST /v1/codes/check and POST /v1/jobs/code-check. A good card stays good. |
| Receipt | Shop order confirmation PNG via POST /v1/order/screenshot. |
| Region | United States, Argentina, Chile and Russia: lookup answers 422 UNSUPPORTED_REGION. A card the store will not accept is REGION_MISMATCH and is not spent. |
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.
| slug | pubg |
|---|---|
| Code | Exactly 18 letters or digits. Case does not matter. |
| Player id | 8–11 digits, always starting with 5. |
| Packs | 60 · 325 · 660 · 1,800 · 3,850 · 8,100 UC. These are the keys of picks. |
| Check | Same routes as Free Fire. Statuses include valid, used, invalid, expired. |
| Receipt | The shop's own ORDER DETAILS card, returned as PNG from /v1/order/screenshot. |
| Refused account | A banned or restricted character comes back as 422 UNSUPPORTED_REGION on lookup and as UNSUPPORTED_REGION rows on redeem. Cards are not spent. |
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.
| slugs | ludo (Diamonds) · ludogold (Gold) |
|---|---|
| Code | Exactly 12 letters or digits. |
| Player id | 6–16 digits. |
| Diamonds packs | 830 · 2,320 · 5,150 · 13,580 · 27,640 · 55,800 · 168,860 · 283,460 — USD 2 · 5 · 10 · 25 · 50 · 100 · 300 · 500 |
| Gold packs | 68,500 · 223,700 · 1,463,320 · 3,666,470 · 9,973,990 · 25,236,460 · 76,000,860 · 126,910,990 |
tier | Required 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. |
| Stock | picks keys are the quantities above, under the matching slug. No tier field — the pack is the key. |
| Check | None as a route. A dead or mismatched card comes back as INVALID_CODE without being spent. |
| Receipt | Yalla Pay result page, rendered as PNG. Language is Arabic for now. |
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.
| slug | jawaker |
|---|---|
| Code | Two 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 id | The Jawaker player number: 6–12 digits. Lookup returns the login as nickname and the account level. |
| Packs | 4,250 · 32,500 · 70,000 · 150,000 · 230,000 · 400,000 · 525,000 · 805,000 · 825,000 tokens. These are the keys of picks. |
| Check | None 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. |
| Redeem | The 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 answer | If 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. |
| Receipt | Jawaker's own “confirm voucher” and “done” dialogs with the login and token amount, returned as PNG from /v1/order/screenshot. |
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 alerts | One message per finished job with at least one credited card, including the amount and a button that renders the receipt. |
|---|---|
| Failure alerts | Sent when a job fails outright (quota, validation, interruption) or when every card in it was refused. |
| Independent | Alerts 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.
# 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
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