Partner API v1 api.mrcardoobot.online

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.

EndpointPurpose
GET /meWho the key belongs to, and your current wallet balance
GET /productsThe full catalogue of products you can buy
GET /products/{id}A single product, to re-check price and availability before buying
POST /purchaseBuy one product and have it delivered
GET /transactionsYour 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

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.

One host only

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.

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

Keep the key server-side

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

StatusMessageCause
401Missing API key…No Authorization header, or not Bearer scheme
401Invalid or disabled API key.Key is wrong, or has been revoked
401API key owner not found.The account behind the key no longer exists
403Requests from this IP address (…) are not allowed…Allow-list is on and your source IP is not on it
403Account is restricted.Your account has been suspended — contact us
403No 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.

  1. Your balance is checkedRead fresh at request time. Too low, and you get 402 and nothing else happens.
  2. 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.
  3. Your wallet is debitedOnly now, and only because delivery was confirmed.
You are never charged for a top-up that did not happen

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

GET/me

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"
200 — Response
{
  "success": true,
  "user": {
    "id": "V3kQ8mAt2LxRc9pWzYb0N",
    "first_name": "Acme",
    "last_name": "Distribution"
  },
  "wallet": { "balance": 482.50 }
}

Catalogue

GET/products?region_id=&in_stock=

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.

No pagination

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

NameTypeDescription
region_idoptionalstring 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_stockoptionalboolean 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

FieldTypeDescription
idstringOpaque 12-character token — pass as product_id when purchasing. Not a UUID; see below.
product_titlestringGame or product name, e.g. Freefire Direct Top-Up (MENA)
sku_namestringThe denomination, e.g. 210 Diamonds
pricenumberWhat your wallet is debited, in currency
currencystringISO code, e.g. USD
in_stockbooleanWhether we can source it right now. false means buying it returns 409 — see Availability
requires_server_idbooleantrue when the game needs a server/zone as well as a player id — see Identifying the player
server_id_labelstring | nullWhat to call that second field in your UI, e.g. Zone ID. null when none is needed.
thumbnailstring | nullAbsolute 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.

A two-field product
{
  "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.

A missing zone does not fail loudly — it delivers to nobody

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.

Out-of-stock products are listed, not hidden

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.

A cached flag is a hint, not a guarantee

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.

Treat 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"
200 — Response
{
  "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"
    }
  ]
}
GET/products/{id}

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.

200 — Response
{
  "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".

Out of stock is not a 404

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

POST/purchase

Buys one product and delivers it. This is the only endpoint that spends money.

Body parameters

NameTypeDescription
product_idrequiredstring The opaque id from the catalogue, e.g. DjMndFXQGfnM.
player_idrequiredstring The in-game account ID the top-up is delivered to. Always send it — see the warning below.
server_idconditionalstring 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.
quantityoptionalinteger Must be 1, which is also the default. Any other value is rejected — send one request per top-up.
Body — two-field product
{
  "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.

Always send player_id — the API will not stop you if you forget

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.

Validate the player ID before you send it

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"
      }'

Successful response

200 — Delivered and charged
{
  "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"
}
FieldTypeDescription
statusstringcompleted or pending — see below
order.order_idintegerOur order reference. Quote this in any support request.
order.chargednumberAmount actually debited
wallet.balancenumberYour balance after the debit
transaction_idstringMatches 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.

ShapeMeaningWhat 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.
Parse defensively

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

StatusCode / messageCharged?Meaning
400product_id is required.NoMissing field
400quantity must be 1 …NoYou sent quantity > 1
400player_id is required for this product.NoTop-up product, no ID supplied
400SERVER_ID_REQUIREDNoThe product has requires_server_id: true and you sent no server_id. Add it and retry.
400INVALID_PLAYER_IDNoThe game rejected the account. Ask the customer to re-check it — retrying the same values will always fail. Read invalid_field.
402Insufficient wallet balance.NoResponse includes balance and required. Top up and retry.
403This wallet is frozen.NoContact us
404Product not found or not available.NoRe-sync your catalogue
409OUT_OF_STOCKNoWe cannot source this denomination right now. Keep the product, mark it unavailable, retry later — see below.
502NO_REGION_MATCHNoWe cannot currently source this product. Usually longer-lived — stop offering the SKU and tell us.
502FULFILMENT_FAILEDNoTransient supplier failure. Safe to retry after a short delay.
500Failed to complete purchase.UnknownDo not blind-retry — see Retries & timeouts
400 — Invalid player ID
{
  "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.

400 — Missing server ID
{
  "success": false,
  "code": "SERVER_ID_REQUIRED",
  "message": "server_id is required for this product…",
  "charged": false
}
402 — Insufficient balance
{
  "success": false,
  "message": "Insufficient wallet balance.",
  "balance": 1.20,
  "required": 1.84
}
409 — Out of stock
{
  "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.

Do not drop the product on a 409

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

GET/transactions?page=&limit=

Your wallet ledger, newest first — every credit (top-ups you make) and debit (purchases). This is the authoritative record for reconciliation.

Query parameters

NameTypeDefaultDescription
pageoptionalinteger11-based. Values below 1 are clamped to 1.
limitoptionalinteger20Rows per page, clamped to the range 1–100.

Transaction object

FieldTypeDescription
idstringtxn_… — matches transaction_id from a purchase
amountnumberAlways positive; read type for direction
typestringdebit or credit
descriptionstringHuman-readable, e.g. Game top-up: Freefire Direct Top-Up (MENA) (12644900867)
sourcestringChannel. Purchases made through this API are partner_api.
statusstringsuccess for a settled entry
created_atstringISO 8601 timestamp, UTC
200 — Response
{
  "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.

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

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

StatusMeaningRetry?
200Success (including both pending variants)No
400Bad request, missing server_id, or invalid player IDNot without changing the input
401Key missing, invalid or revokedNo — fix the key
402Wallet balance too lowAfter topping up
403IP not allowed, account restricted, or wallet frozenNo — contact us
404Product does not exist or is unavailableNo — re-sync catalogue
409Product is out of stock — exists, but cannot be sourced nowLater, not immediately
500Unexpected server errorOnly after checking — see below
502Supplier could not fulfil. Nothing charged.Yes, with backoff

Retries & timeouts

There is no idempotency key

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:

  1. Wait a few seconds.
  2. Call GET /transactions and look for a debit whose description contains the player ID you just sent, dated within the last few minutes.
  3. If it is there, the order went through — do not retry.
  4. If it is not there after a couple of checks, it is safe to send the purchase again.

Safe to retry automatically

Never retry

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.

Node.js 18+
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.