Partner API
A REST API for buying game top-ups programmatically. Browse the catalogue, send a purchase, and the top-up is delivered to the player's account — billed against your prepaid wallet. Every response is JSON, and every request is authenticated with an API key.
Overview
The API is deliberately small — five endpoints. In normal use you will call products to sync the catalogue and purchase to fulfil an order; the rest exist for reconciliation.
| Endpoint | Purpose |
|---|---|
GET /me | Who the key belongs to, and your current wallet balance |
GET /products | The full catalogue of products you can buy |
GET /products/{id} | A single product, to re-check price and availability before buying |
POST /purchase | Buy one product and have it delivered |
GET /transactions | Your wallet history — every credit and debit |
The wallet is prepaid. Top it up out of band; the API only ever spends from it and never issues credit. If the balance will not cover a purchase, the request is rejected before anything is bought.
Base URL
https://api.mrcardoobot.online/api/v1/partner
Every path in this document is relative to that base — /me means
https://api.mrcardoobot.online/api/v1/partner/me. HTTPS is required; plain HTTP is
redirected and should never be used to carry your key.
Partner traffic is served from api.mrcardoobot.online. Other hostnames belonging
to this service run the internal admin panel and will reject partner calls — point your
integration at the base URL above and nothing else.
Authentication
Send your API key as a bearer token on every request. Keys look like
sk_live_ followed by 64 hexadecimal characters.
Authorization: Bearer sk_live_YOUR_API_KEY
Keys are issued to you directly — there is no self-service signup endpoint. We store only a SHA-256 hash of your key, so it cannot be recovered or shown to you again after issue. If you lose it, we revoke it and issue a new one.
IP allow-listing
A key can optionally be restricted to a fixed set of source IP addresses. This is off by default — a new key works from anywhere. Ask us to enable it and supply your egress IPs; calls from any other address then return 403 naming the address that was refused, which makes a misconfigured proxy easy to spot.
The key spends real money from your wallet with no second factor. Never embed it in a mobile app, browser page, or anything else a customer can read. All calls should originate from your own backend.
Authentication failures
| Status | Message | Cause |
|---|---|---|
| 401 | Missing API key… | No Authorization header, or not Bearer scheme |
| 401 | Invalid or disabled API key. | Key is wrong, or has been revoked |
| 401 | API key owner not found. | The account behind the key no longer exists |
| 403 | Requests from this IP address (…) are not allowed… | Allow-list is on and your source IP is not on it |
| 403 | Account is restricted. | Your account has been suspended — contact us |
| 403 | No wallet found for this account. | Account has no wallet provisioned — contact us |
How a top-up works
Understanding the ordering matters, because it determines when you are charged.
- Your balance is checkedRead fresh at request time. Too low, and you get 402 and nothing else happens.
- The top-up is bought and deliveredWe purchase from our supplier and the credit lands on the player's account. Your money is still untouched at this point.
- Your wallet is debitedOnly now, and only because delivery was confirmed.
A wrong player ID or a supplier outage costs you nothing — those responses carry
"charged": false explicitly. The only exception runs the other way: if delivery
succeeds but the billing step fails, you keep the top-up and we settle it manually. That case
returns 200 with "status": "pending".
Account & balance
Returns the account the key belongs to and its current wallet balance. Takes no parameters. Useful as a health check and as a cheap way to confirm a key works.
curl https://api.mrcardoobot.online/api/v1/partner/me \
-H "Authorization: Bearer $API_KEY"
const res = await fetch( "https://api.mrcardoobot.online/api/v1/partner/me", { headers: { Authorization: `Bearer ${process.env.API_KEY}` } } ); const data = await res.json(); console.log(data.wallet.balance);
$ch = curl_init("https://api.mrcardoobot.online/api/v1/partner/me"); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("API_KEY")], ]); $data = json_decode(curl_exec($ch), true); echo $data["wallet"]["balance"];
import os, requests r = requests.get( "https://api.mrcardoobot.online/api/v1/partner/me", headers={"Authorization": f"Bearer {os.environ['API_KEY']}"}, timeout=30, ) print(r.json()["wallet"]["balance"])
{
"success": true,
"user": {
"id": "V3kQ8mAt2LxRc9pWzYb0N",
"first_name": "Acme",
"last_name": "Distribution"
},
"wallet": { "balance": 482.50 }
}
Catalogue
Lists every product available to you, sorted by product name and then by price ascending.
Only visible, enabled and priced products are returned. Each carries an
in_stock flag saying whether it can be bought right now.
This endpoint returns the entire catalogue in one response — there is no
page or limit. Cache the result and refresh it periodically
rather than calling it before every purchase.
Query parameters
| Name | Type | Description |
|---|---|---|
| region_idoptional | string | Restrict the list to a single region. The region identifier is internal and is not returned in responses — use this only if we have given you the value for your market. Omit it to receive everything. |
| in_stockoptional | boolean | true returns only products that can be bought right now;
false only those that cannot. Omit it — the default — to receive both,
which is what most integrations want. See Availability. |
Product object
| Field | Type | Description |
|---|---|---|
| id | string | Opaque 12-character token — pass as product_id when purchasing. Not a UUID; see below. |
| product_title | string | Game or product name, e.g. Freefire Direct Top-Up (MENA) |
| sku_name | string | The denomination, e.g. 210 Diamonds |
| price | number | What your wallet is debited, in currency |
| currency | string | ISO code, e.g. USD |
| in_stock | boolean | Whether we can source it right now. false means buying it returns 409 — see Availability |
| requires_server_id | boolean | true when the game needs a server/zone as well as a player id — see Identifying the player |
| server_id_label | string | null | What to call that second field in your UI, e.g. Zone ID. null when none is needed. |
| thumbnail | string | null | Absolute image URL — see Product images |
Identifying the player — one field or two
Most games identify a player with a single value, and player_id is the whole
answer. Some need two: Mobile Legends identifies an account as
5127957161 (1234) — a user id and the zone it lives on. The catalogue
tells you which kind you are looking at.
{
"id": "DjMndFXQGfnM",
"product_title": "Mobile Legends: Bang Bang",
"requires_server_id": true,
"server_id_label": "Zone ID"
}
When requires_server_id is true you must send
server_id on purchase, and you should label your input
with server_id_label so the customer knows what to look for. When it is
false, server_id_label is null and any
server_id you send is ignored.
This is the one field where guessing costs a real top-up. An order sent without the zone
is not rejected as invalid: it is a well-formed order for an account that
does not exist, so it can be accepted and credit no one. Drive your form from
requires_server_id rather than from a hardcoded list of games.
Availability — in_stock
"in_stock": false means we cannot source that denomination at this moment.
The product is real, still priced, and will come back — it is not
discontinued or withdrawn.
They stay in the response by default, flagged rather than removed, so a catalogue you mirror stays stable instead of losing and regaining rows every time our supply moves. Render them greyed out or unselectable — which is exactly what our own storefront does — rather than deleting them.
Availability is independent of price: a cheap denomination can be out of stock while an expensive one is not, so never infer one from the other. It also changes on its own, without any action from you.
Stock can run out between your catalogue refresh and your purchase. Reading
in_stock avoids most failed attempts, but the authoritative check is
POST /purchase itself — always handle the
409 it can return. Nothing is charged when it does.
id as an opaque token
It is a 12-character string of letters, digits, - and _ — for
example DjMndFXQGfnM or _YqriHh0pfSc. It is not a
UUID. Store it verbatim, compare it exactly, and do not validate it against a
UUID pattern or assume a fixed character set. A product keeps the same id for its
lifetime.
The same product_title appears many times with different ids — those are
separate denominations. Always key off id, never the title.
curl https://api.mrcardoobot.online/api/v1/partner/products \
-H "Authorization: Bearer $API_KEY"
const res = await fetch( "https://api.mrcardoobot.online/api/v1/partner/products", { headers: { Authorization: `Bearer ${process.env.API_KEY}` } } ); const { products } = await res.json(); for (const p of products) { console.log(p.id, p.product_title, p.sku_name, p.price, p.currency); }
import os, requests r = requests.get( "https://api.mrcardoobot.online/api/v1/partner/products", headers={"Authorization": f"Bearer {os.environ['API_KEY']}"}, timeout=30, ) for p in r.json()["products"]: print(p["id"], p["product_title"], p["sku_name"], p["price"])
{
"success": true,
"count": 51,
"products": [
{
"id": "_YqriHh0pfSc",
"product_title": "Baloot (Direct Top Up)",
"sku_name": "32800 Coins TopUp",
"price": 1.29,
"currency": "USD",
"in_stock": true,
"requires_server_id": false,
"server_id_label": null,
"thumbnail": "https://api.mrcardoobot.online/api/v1/media/product/_YqriHh0pfSc"
},
{
"id": "DjMndFXQGfnM",
"product_title": "Freefire Direct Top-Up (MENA)",
"sku_name": "210 Diamonds",
"price": 1.84,
"currency": "USD",
"in_stock": false, // listed, but not buyable right now
"requires_server_id": true, // also send server_id when buying
"server_id_label": "Zone ID",
"thumbnail": "https://api.mrcardoobot.online/api/v1/media/product/DjMndFXQGfnM"
}
]
}
Fetches a single product. The response object is identical to one entry of the list above,
wrapped in product instead of products. Call this immediately
before a purchase if you display a price to your customer and want to be certain both the
price and in_stock are current.
{
"success": true,
"product": {
"id": "DjMndFXQGfnM",
"product_title": "Freefire Direct Top-Up (MENA)",
"sku_name": "210 Diamonds",
"price": 1.84,
"currency": "USD",
"in_stock": true,
"requires_server_id": false,
"server_id_label": null,
"thumbnail": "https://api.mrcardoobot.online/api/v1/media/product/DjMndFXQGfnM"
}
}
404 Product not found or not available. — the id is
unknown, or the product has been hidden, disabled or unpriced since you cached it. Treat a
404 here as "remove from my catalogue and re-sync".
A product we cannot currently source still returns 200, with
"in_stock": false. The distinction matters: a 404 means gone, re-sync,
while in_stock: false means still ours, just unavailable today. Only
the 404 should remove a row from your catalogue.
Purchase
Buys one product and delivers it. This is the only endpoint that spends money.
Body parameters
| Name | Type | Description |
|---|---|---|
| product_idrequired | string | The opaque id from the catalogue, e.g. DjMndFXQGfnM. |
| player_idrequired | string | The in-game account ID the top-up is delivered to. Always send it — see the warning below. |
| server_idconditional | string | The server / zone the account lives on. Required when the product
has "requires_server_id": true, ignored otherwise.
zone_id is accepted as an alias. See
Identifying the player. |
| quantityoptional | integer | Must be 1, which is also the default. Any other value is rejected —
send one request per top-up. |
{
"product_id": "DjMndFXQGfnM",
"player_id": "5127957161",
"server_id": "1234"
}
The order is recorded against "5127957161 (1234)" — id and zone together —
and that composite is the player_id returned in the response and shown in
/transactions.
Every product currently in the catalogue is a direct top-up that is delivered to a game
account. If you omit player_id, the request is not rejected:
it goes to the supplier without an account number, and will not credit anyone. Make it a
required field in your own checkout.
Each attempt with a wrong ID consumes supplier resources even though you are not billed. Enforce the game's expected ID format in your own checkout — Free Fire IDs are 10–11 digits, for instance — rather than passing whatever the customer typed straight through.
curl -X POST https://api.mrcardoobot.online/api/v1/partner/purchase \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "product_id": "DjMndFXQGfnM", "player_id": "12644900867" }'
const res = await fetch( "https://api.mrcardoobot.online/api/v1/partner/purchase", { method: "POST", headers: { Authorization: `Bearer ${process.env.API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ product_id: "DjMndFXQGfnM", player_id: "12644900867", }), } ); const data = await res.json(); if (!data.success) throw new Error(data.code ?? data.message); console.log("order", data.order.order_id, "charged", data.order.charged);
$ch = curl_init("https://api.mrcardoobot.online/api/v1/partner/purchase"); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 120, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("API_KEY"), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => json_encode([ "product_id" => "DjMndFXQGfnM", "player_id" => "12644900867", ]), ]); $data = json_decode(curl_exec($ch), true);
import os, requests r = requests.post( "https://api.mrcardoobot.online/api/v1/partner/purchase", headers={"Authorization": f"Bearer {os.environ['API_KEY']}"}, json={ "product_id": "DjMndFXQGfnM", "player_id": "12644900867", }, timeout=120, # see "Retries & timeouts" ) data = r.json()
Successful response
{
"success": true,
"message": "Purchase successful.",
"status": "completed",
"order": {
"order_id": 10482,
"product_id": "DjMndFXQGfnM",
"product_name": "Freefire Direct Top-Up (MENA)",
"sku_name": "210 Diamonds",
"player_id": "12644900867",
"quantity": 1,
"charged": 1.84,
"currency": "USD"
},
"wallet": { "balance": 480.66 },
"transaction_id": "txn_7b2e9c4a1f03"
}
| Field | Type | Description |
|---|---|---|
| status | string | completed or pending — see below |
| order.order_id | integer | Our order reference. Quote this in any support request. |
| order.charged | number | Amount actually debited |
| wallet.balance | number | Your balance after the debit |
| transaction_id | string | Matches the id in /transactions |
Pending outcomes
A 200 with "status": "pending" means
the order went through but needs a human to close it out. There are two
variants, and both are successes from your side — do not retry either.
| Shape | Meaning | What to do |
|---|---|---|
status: "pending" with a full order object |
Delivered and charged, but flagged for review — usually the supplier had not yet confirmed the order when we responded. | Treat as sold. Reconcile against /transactions later. |
status: "pending", charged: false, no order |
Delivered, but the billing step failed. Message reads
Top-up delivered. Billing is being reconciled. |
The customer has their credit. We settle the charge manually — expect the debit to appear later. |
The second variant has no order object and therefore no
order_id. Code that does data.order.order_id unconditionally
will throw on it. Check data.order exists before reading into it.
Purchase errors
| Status | Code / message | Charged? | Meaning |
|---|---|---|---|
| 400 | product_id is required. | No | Missing field |
| 400 | quantity must be 1 … | No | You sent quantity > 1 |
| 400 | player_id is required for this product. | No | Top-up product, no ID supplied |
| 400 | SERVER_ID_REQUIRED | No | The product has requires_server_id: true and you sent no server_id. Add it and retry. |
| 400 | INVALID_PLAYER_ID | No | The game rejected the account. Ask the customer to re-check it — retrying the same values will always fail. Read invalid_field. |
| 402 | Insufficient wallet balance. | No | Response includes balance and required. Top up and retry. |
| 403 | This wallet is frozen. | No | Contact us |
| 404 | Product not found or not available. | No | Re-sync your catalogue |
| 409 | OUT_OF_STOCK | No | We cannot source this denomination right now. Keep the product, mark it unavailable, retry later — see below. |
| 502 | NO_REGION_MATCH | No | We cannot currently source this product. Usually longer-lived — stop offering the SKU and tell us. |
| 502 | FULFILMENT_FAILED | No | Transient supplier failure. Safe to retry after a short delay. |
| 500 | Failed to complete purchase. | Unknown | Do not blind-retry — see Retries & timeouts |
{
"success": false,
"code": "INVALID_PLAYER_ID",
"invalid_field": "player_id", // or "both" on a two-field product
"message": "The game rejected this Player ID.",
"charged": false
}
On a two-field product invalid_field is "both". The game does not
tell us which value was wrong — a correct id in the wrong zone fails identically to a bad
id — so ask the customer to re-check both rather than blaming one.
{
"success": false,
"code": "SERVER_ID_REQUIRED",
"message": "server_id is required for this product…",
"charged": false
}
{
"success": false,
"message": "Insufficient wallet balance.",
"balance": 1.20,
"required": 1.84
}
{
"success": false,
"code": "OUT_OF_STOCK",
"message": "This product is out of stock right now. Try again later, or choose another product.",
"charged": false
}
The stock check runs before your balance is read, because availability is a property of the product and not of your account — you will get this even with an empty wallet, and a 402 therefore always means the product itself was available.
No order was created and nothing was charged, so there is nothing to reconcile. Unlike a
404, the product is still yours to sell — keep it, mark it
unavailable, and offer the customer another denomination. Reading
in_stock from the catalogue avoids most of these, but
never all: stock can run out between your refresh and your purchase.
Transactions
Your wallet ledger, newest first — every credit (top-ups you make) and debit (purchases). This is the authoritative record for reconciliation.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
| pageoptional | integer | 1 | 1-based. Values below 1 are clamped to 1. |
| limitoptional | integer | 20 | Rows per page, clamped to the range 1–100. |
Transaction object
| Field | Type | Description |
|---|---|---|
| id | string | txn_… — matches transaction_id from a purchase |
| amount | number | Always positive; read type for direction |
| type | string | debit or credit |
| description | string | Human-readable, e.g. Game top-up: Freefire Direct Top-Up (MENA) (12644900867) |
| source | string | Channel. Purchases made through this API are partner_api. |
| status | string | success for a settled entry |
| created_at | string | ISO 8601 timestamp, UTC |
{
"success": true,
"count": 137, // total rows, not rows on this page
"page": 1,
"limit": 20,
"transactions": [
{
"id": "txn_7b2e9c4a1f03",
"amount": 1.84,
"type": "debit",
"description": "Game top-up: Freefire Direct Top-Up (MENA) (12644900867)",
"source": "partner_api",
"status": "success",
"created_at": "2026-07-31T09:14:22.481Z"
}
]
}
count is the total number of rows in your ledger, so
Math.ceil(count / limit) gives the number of pages.
Product images
The thumbnail field is an absolute URL on our domain that serves the product image
directly. It is public — no API key, no headers — precisely so it can be dropped
into an <img> tag, which cannot send an Authorization header.
<img src="https://api.mrcardoobot.online/api/v1/media/product/DjMndFXQGfnM" alt="Freefire Direct Top-Up — 210 Diamonds">
Images change rarely. Cache or mirror them on your own CDN rather than proxying every page view through us.
Error format
Every error carries the same envelope. The HTTP status is the primary signal; the optional
code field further identifies purchase failures.
{
"success": false,
"message": "Human-readable explanation.",
"code": "MACHINE_CODE" // purchase failures only
}
Branch on success and the HTTP status, and on code where present.
Never match on message — the wording may change without notice.
| Status | Meaning | Retry? |
|---|---|---|
| 200 | Success (including both pending variants) | No |
| 400 | Bad request, missing server_id, or invalid player ID | Not without changing the input |
| 401 | Key missing, invalid or revoked | No — fix the key |
| 402 | Wallet balance too low | After topping up |
| 403 | IP not allowed, account restricted, or wallet frozen | No — contact us |
| 404 | Product does not exist or is unavailable | No — re-sync catalogue |
| 409 | Product is out of stock — exists, but cannot be sourced now | Later, not immediately |
| 500 | Unexpected server error | Only after checking — see below |
| 502 | Supplier could not fulfil. Nothing charged. | Yes, with backoff |
Retries & timeouts
Two identical POST /purchase calls are two separate purchases and two separate
debits. The API has no way to recognise a retry as a duplicate, so retry logic is entirely
your responsibility.
A purchase is a synchronous call that talks to an upstream supplier, so it can take tens of seconds — typically under a minute, occasionally more. Set a client timeout of at least 90 seconds; 120 is a safe default. A short timeout is the single most common cause of a double top-up: your client gives up, your code retries, and the first order completes anyway.
If a purchase times out or returns 500
The outcome is genuinely unknown at that point. Resolve it by reading, not by writing:
- Wait a few seconds.
- Call
GET /transactionsand look for adebitwhosedescriptioncontains the player ID you just sent, dated within the last few minutes. - If it is there, the order went through — do not retry.
- If it is not there after a couple of checks, it is safe to send the purchase again.
Safe to retry automatically
- 502 with either code — nothing was charged. Back off exponentially and cap the attempts.
- 409
OUT_OF_STOCK— nothing was charged, but do not hammer it: stock returns on a human timescale, not a network one. Wait minutes rather than seconds, or drop the attempt and let your next catalogue refresh pick the product back up.
Never retry
- 400
INVALID_PLAYER_ID— every attempt will fail identically until the customer supplies a different ID. - 200 of any kind, including both
pendingvariants — the order exists. - 401 / 403 — retrying will not change the answer.
Rate limits
There is no published rate limit. Keep concurrency modest — a handful of in-flight purchases — and cache the catalogue instead of re-fetching it per order. Abusive traffic may be throttled at the network layer without notice.
Full example
An end-to-end integration in Node.js: check the balance, resolve the SKU, buy it, and handle every outcome the API can return.
const BASE = "https://api.mrcardoobot.online/api/v1/partner"; const KEY = process.env.API_KEY; async function call(path, options = {}) { const res = await fetch(BASE + path, { ...options, headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", ...options.headers, }, signal: AbortSignal.timeout(120_000), // purchases are slow }); return { status: res.status, body: await res.json() }; } async function topUp(productId, playerId, serverId) { // 1. Confirm we can afford it before touching the customer's order. const { body: account } = await call("/me"); const { body: cat } = await call(`/products/${productId}`); if (!cat.success) throw new Error("Product unavailable — re-sync catalogue"); // Cheap pre-check. Not authoritative — /purchase can still return 409. if (!cat.product.in_stock) { return { ok: false, retryable: false, reason: "out_of_stock" }; } // Two-field game: the zone is not optional, and omitting it delivers to // nobody rather than failing. Collect it before you get here. if (cat.product.requires_server_id && !serverId) { throw new Error(`This product needs a ${cat.product.server_id_label}`); } if (account.wallet.balance < cat.product.price) { throw new Error("Top up your wallet before selling this"); } // 2. Buy it. const { status, body } = await call("/purchase", { method: "POST", body: JSON.stringify({ product_id: productId, player_id: playerId, ...(cat.product.requires_server_id ? { server_id: serverId } : {}), }), }); // 3. Handle every outcome explicitly. if (body.success) { // Both `pending` variants land here. Note that `order` may be absent. return { ok: true, orderId: body.order?.order_id ?? null, charged: body.order?.charged ?? 0, needsFollowUp: body.status === "pending", }; } if (body.code === "INVALID_PLAYER_ID") { // invalid_field is "both" when the game takes an id AND a zone. return { ok: false, retryable: false, reason: "bad_account", recheck: body.invalid_field }; } if (body.code === "SERVER_ID_REQUIRED") { return { ok: false, retryable: false, reason: "missing_server_id" }; } if (status === 409) { // Sold out between the catalogue read and now. Keep the product, // mark it unavailable, and retry on a slow timescale — not at once. return { ok: false, retryable: false, reason: "out_of_stock" }; } if (status === 402) { return { ok: false, retryable: false, reason: "insufficient_funds" }; } if (status === 502) { // Nothing was charged — safe to retry with backoff. return { ok: false, retryable: true, reason: body.code }; } // 500 or a timeout: outcome unknown. Check /transactions before retrying. return { ok: false, retryable: false, reason: "unknown_verify_first" }; }
Support
When reporting a problem, include the order_id or transaction_id, the
player_id, and the approximate time in UTC. That is enough for us to find the order
— we do not need your API key, and you should never send it to us or anyone else.
To request a key, change your IP allow-list, or ask which products need a
player_id, contact your account manager.