OYUN QURDU Corporate
API reference · v1

Oyun Qurdu Corporate API

A REST API for buying game top-ups and digital gift cards at wholesale prices and delivering them inside your own product. Orders are paid from a prepaid balance on your account.

Base URL
https://corporative.oyunqurdu.az/api/v1
  • All requests and responses are JSON, UTF-8. Messages inside API responses are in English, so your code can rely on them.
  • HTTPS only. Plain HTTP is redirected and must not be used for API calls.
  • The version is part of the path (/v1). We add fields without changing the version; we never remove or rename them within v1.
  • Two kinds of products: top-up (credited directly to a game account — you send the player ID) and code (gift cards — you receive the code in the response).

Quick start

1. Sign in and create an API key on the API keys page. 2. Make sure your balance is above zero. 3. Find a product, check its live price and order it:

# 1. find the product code
curl "https://corporative.oyunqurdu.az/api/v1/products?category=pubg-mobile" \
  -H "Authorization: Bearer $OQ_API_KEY"

# 2. live price right now
curl "https://corporative.oyunqurdu.az/api/v1/products/PUBGM-660-UC/quote" \
  -H "Authorization: Bearer $OQ_API_KEY"

# 3. order it (max_unit_price locks the quoted price)
curl -X POST https://corporative.oyunqurdu.az/api/v1/orders \
  -H "Authorization: Bearer $OQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"product": "PUBGM-660-UC", "fields": {"player_id": "5123456789"}, "reference": "INV-10045"}'

# 4. read the result (or wait for the webhook)
curl https://corporative.oyunqurdu.az/api/v1/orders/reference/INV-10045 \
  -H "Authorization: Bearer $OQ_API_KEY"

Authentication

Every request (except GET /ping and GET /openapi.json) needs an API key. Send it as a bearer token:

Authorization: Bearer oqc_Ab3dE5fG7h_••••••••••••••••••••••••••••••••••••••••

The header X-API-Key: oqc_… is accepted as well.

  • Keys start with oqc_. You can have several (e.g. one per server) and revoke each separately.
  • A key is shown once, when you create it. We store only a fingerprint; a lost key cannot be recovered — create a new one.
  • Each key can be limited: allowed IP addresses, allowed websites, a daily spending limit, or read-only (no orders).
  • Keep keys on your server. Never put them in a mobile app, browser code or a public repository.

Using it from a website

A key that is inside a web page can be read by anyone who opens the page. Restricting it to your domain stops other websites from using it in a browser, but whoever copies the key can still send requests from a script and pretend to be your site. So:

  • Best: call the API from your server (or a small proxy) and keep the key there. Your page talks to your server, your server talks to us.
  • Google Sites has no server of its own, and embedded code runs on a shared Google domain (*.googleusercontent.com) — a domain lock cannot tell your site apart from any other. Use a Google Apps Script web app as the proxy (example below): the key stays in the script's properties on Google's servers and never reaches the browser.
  • If you must call from the browser, add the site's domain to the key (Allowed websites), give it a daily spending limit, and consider a separate read-only key for prices. A key with websites only answers requests that come from those sites; a key without websites refuses browser requests altogether.
Google Apps Script · Code.gs
// Extensions → Apps Script. Project Settings → Script properties → OQ_API_KEY = oqc_…
// Deploy → New deployment → Web app. Embed its URL in Google Sites.
const API = "https://corporative.oyunqurdu.az/api/v1";
const KEY = PropertiesService.getScriptProperties().getProperty("OQ_API_KEY");

function call_(method, path, body) {
  const res = UrlFetchApp.fetch(API + path, {
    method: method,
    contentType: "application/json",
    headers: { Authorization: "Bearer " + KEY },
    payload: body ? JSON.stringify(body) : undefined,
    muteHttpExceptions: true,
  });
  return JSON.parse(res.getContentText());
}

function getPrice(code) {                       // google.script.run.getPrice("PUBGM-660-UC")
  return call_("get", "/products/" + encodeURIComponent(code) + "/quote");
}

function placeOrder(code, playerId, reference) { // add your own checks here
  return call_("post", "/orders", {
    product: code, fields: { player_id: playerId }, reference: reference,
  });
}

function doGet() { return HtmlService.createHtmlOutputFromFile("Index"); }
Anyone who can open the web app can call it. Add your own checks in the script (for example, only after your customer has paid) and keep a daily spending limit on the key.

Requests & responses

  • Money is a string with two decimals ("9.17") — never a float. Currency is the account currency (USD).
  • Times are ISO 8601 in UTC (2026-10-24T09:12:44Z).
  • IDs have prefixes: ord_ orders, txn_ transactions, evt_ webhook events, acc_ accounts.
  • Lists are paginated with page and per_page (max 100) and return data plus pagination.
  • Every response carries X-Request-Id. Include it when you contact us about a request.
  • Trailing slashes are optional: /orders and /orders/ are the same.

Errors

Errors use standard HTTP status codes and always have the same shape. Use error.code in your logic — the message may change.

{
  "error": {
    "code": "insufficient_balance",
    "message": "Your balance is too low for this order.",
    "retryable": false,
    "details": {
      "balance": "4.20",
      "required": "9.17",
      "shortfall": "4.97"
    },
    "doc_url": "https://corporative.oyunqurdu.az/docs/#error-insufficient_balance"
  },
  "request_id": "req_3f9a1c0b7d2e4a6b8c1d"
}

Every error also tells you whether retrying can help (retryable) and links to its row below (doc_url).

codeHTTPRetry?MeaningWhat to do
unauthorized401 no No API key was sent.Send `Authorization: Bearer <key>` (or `X-API-Key`).
malformed_api_key401 no The value is not an API key (keys look like `oqc_…_…`).Copy the whole key; check for spaces or quotes.
invalid_api_key401 no The API key does not exist.Check the key or create a new one in the dashboard.
api_key_revoked401 no The key was revoked.Create a new key in the dashboard.
account_disabled403 no The account is disabled.Contact us.
ip_not_allowed403 no The request came from an IP that is not on the key's allowlist.Add the IP to the key in the dashboard. `details.ip` shows the IP we saw.
origin_not_allowed403 no The request came from a website that is not on the key's domain list.Add the domain to the key in the dashboard. `details.origin` shows what we saw.
key_read_only403 no This key can read but cannot place orders.Use a key with ordering allowed, or turn it on in the dashboard.
invalid_json400 no The body is not a valid JSON object.Send a JSON object with `Content-Type: application/json`.
unsupported_media_type415 no The body is not JSON.Set `Content-Type: application/json`.
payload_too_large413 no The request body is larger than 32 KB.Send a smaller body.
validation_error422 no One or more body parameters are missing or invalid.`details` lists each parameter with a `code` and `message`.
invalid_parameter422 no A query parameter is invalid.`details` names the parameter and the allowed values.
invalid_fields422 no Product fields (player ID, server …) are missing or invalid.`details.fields.<key>` has a `code`: required, unknown_field, too_long, invalid_option, invalid_type, invalid_characters.
not_found404 no No such endpoint.Check the path and the `/v1` prefix.
method_not_allowed405 no The HTTP method is not supported here.See the `Allow` header.
product_not_found404 no No product with this code.List products with `GET /products`.
product_discontinued410 no The product is no longer sold.Remove it from your catalogue; pick another product.
product_unavailable409 yes The product is temporarily unavailable.Try again later or offer another product.
out_of_stock409 yes Not enough stock for the requested quantity.`details.available` is what is left. Lower the quantity or retry later.
quantity_out_of_range422 no Quantity is outside the product's limits.`details` has `min` and `max`.
not_supported422 no The operation is not available for this product.
insufficient_balance402 no Your balance is lower than the order total.Top up. `details` has `balance`, `required` and `shortfall`.
order_limit_exceeded422 no The order total is above your account's per-order limit.Split the order or ask us to raise the limit. `details` has `total` and `limit`.
key_daily_limit_exceeded422 no This key has reached its daily spending limit.Wait until 00:00 UTC or raise the key's limit in the dashboard.
price_changed409 yes The current price is higher than your `max_unit_price`.Re-read the price (`details.unit_price`) and retry.
duplicate_reference409 no This `reference` was already used with different parameters.Use a new reference. `details.order_id` is the existing order.
order_not_found404 no No order with this ID or reference.Check the ID.
rate_limited429 yes Too many requests in this minute.Wait `Retry-After` seconds.
maintenance503 yes Ordering is paused for a short maintenance.Retry after `Retry-After` seconds. Your balance was not charged. Browsing still works.
service_unavailable503 yes A part of the service is temporarily unavailable.Retry later. Nothing was charged.
internal_error500 yes Unexpected error on our side.Retry. If it repeats, send us the `request_id`.

Field errors

validation_error (body parameters) and invalid_fields (product fields) explain every problem separately:

{
  "error": {
    "code": "invalid_fields",
    "message": "Some product fields are missing or invalid.",
    "retryable": false,
    "details": {"fields": {
      "server_id": {"code": "required", "message": "Server ID is required."},
      "zone":      {"code": "unknown_field", "message": "This product does not take `zone`. Expected: player_id, server_id."}
    }},
    "doc_url": "https://corporative.oyunqurdu.az/docs/#error-invalid_fields"
  },
  "request_id": "req_3f9a1c0b7d2e4a6b8c1d"
}
Was I charged? If POST /orders returns any error, your balance was not touched. Money moves only when an order is created (201).
Safe to retry? Errors with "retryable": true may succeed later — retry with backoff (and the same reference). The others need a change on your side first.

Maintenance

Occasionally ordering is paused for a short maintenance. During it:

  • POST /orders returns 503 maintenance with a Retry-After header (seconds). Nothing is charged.
  • Everything else — products, prices, balance, orders, transactions — keeps working.
  • GET /ping and GET /account show it in service:
{"status": "ok", "version": "1.0.0",
 "service": {"status": "maintenance", "message": "Ordering is paused for a short maintenance…", "retry_after": 300}}

Normally service.status is operational. Show your customers a short "try again in a few minutes" and retry after Retry-After.

Rate limits

Each account has a per-minute request limit (default 120, shared by all its keys). Every response shows where you stand:

X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 41        # seconds until the window resets

Over the limit you get 429 rate_limited with a Retry-After header. Need more? Contact us.

Idempotency

Networks fail. If a POST /orders times out you cannot know whether the order was created — so always send your own reference (your invoice or transaction ID, unique per order):

  • Same reference, same product and quantity → you get the existing order back with 200 and Idempotent-Replayed: true. Nothing is charged twice.
  • Same reference, different parameters → 409 duplicate_reference.
  • Look an order up by reference at any time: GET /orders/reference/{reference}.

Account & balance

GET/account

Your company, login, balance, rate limit, webhook settings and service status.

GET/balance
{"balance": "1250.40", "currency": "USD"}

The balance is topped up by our team after you transfer funds. Every movement is listed under transactions.

Categories

GET/categories

Games and services with the regions available for each.

{"data": [
  {"code": "free-fire", "name": "Free Fire", "regions": ["CIS", "EU", "ID"], "product_count": 37},
  {"code": "steam", "name": "Steam Wallet", "regions": ["CIS", "GLOBAL"], "product_count": 29}
]}

Products

GET/products

All products with your prices. Looking up codes needs no market call — it is instant. Filters:

categoryCategory code, e.g. free-fire
regionGLOBAL, EU, TR, US, CIS, RU, ID
typetopup or code
qSearch in name and code
in_stocktrue — only products that can be ordered now
livetrue — re-price the category from the market now (needs category)
page, per_pagePagination (per_page ≤ 100)
GET/products/{code}
{
  "code": "PUBGM-660-UC",
  "name": "PUBG Mobile — 660 UC",
  "category": "pubg-mobile",
  "category_name": "PUBG Mobile",
  "region": "GLOBAL",
  "type": "topup",
  "price": "9.17",
  "currency": "USD",
  "in_stock": true,
  "stock": null,
  "min_quantity": 1,
  "max_quantity": 10,
  "fields": [
    {
      "key": "player_id",
      "label": "Player ID",
      "type": "text",
      "required": true
    }
  ],
  "description": "Enter your Player ID before placing the order."
}
codeStable product code. It never changes — store it in your system.
typetopup: credited to the game account in fields. code: you receive gift card codes.
priceYour unit price, refreshed from the market at least every 15 minutes (price_updated_at). For the price right now use the quote endpoint.
stockUnits available for codes. null means not limited by stock (top-ups).
min_quantity / max_quantityAllowed quantity per order.
fieldsWhat you must send in fields when ordering — e.g. player_id, server_id. Empty for codes.

Live prices

GET/products/{code}/quote?quantity=2

Asks the market for the price right now, adds your margin and returns your unit price and total. Use it just before you show the price to your customer.

{
  "product": "PUBGM-660-UC",
  "name": "PUBG Mobile — 660 UC",
  "quantity": 2,
  "unit_price": "9.17",
  "total": "18.34",
  "currency": "USD",
  "available": true,
  "in_stock": true,
  "stock": null,
  "live": true,
  "quoted_at": "2026-10-24T09:12:40Z",
  "valid_until": "2026-10-24T09:13:40Z",
  "hint": "Send max_unit_price with the order to lock this price."
}
  • live: true — priced just now. false — the market did not answer, this is the last known price.
  • Prices can move. Send max_unit_price (the quoted unit_price) with the order: if the price went up in between, the order is refused with 409 price_changed and nothing is charged.
  • For a whole category at once: GET /products?category=pubg-mobile&live=true.

Validate a player ID

POST/products/{code}/validate

For top-up products: check the game account before charging your customer. Not every game supports it — you then get 422 not_supported.

POST /products/MLBB-GLOBAL-86-DIAMONDS/validate
{"fields": {"player_id": "123456789", "server_id": "2001"}}

→ {"valid": true, "player_name": "ShadowFox"}

Create an order

POST/orders
FieldRequiredDescription
productyesProduct code.
quantitynoDefault 1. Must be within the product's min/max.
fieldsfor top-upsObject with every key from the product's fields. Unknown keys are rejected.
referencerecommendedYour unique ID (1–64 chars: letters, digits, _ . : -). Makes the request safe to retry.
max_unit_pricenoRefuse the order with 409 price_changed if the unit price is now higher.
callback_urlnohttps URL that receives this order's webhooks, in addition to your account webhook.

The total is taken from your balance immediately and the order starts as processing:

201 Created
{
  "id": "ord_7kq2m9xw4hz8n3pd5t",
  "reference": "INV-10045",
  "status": "processing",
  "status_message": "The order is being fulfilled.",
  "product": {
    "code": "STEAM-GLOBAL-20-USD",
    "name": "Steam Wallet (Global) — 20 USD"
  },
  "quantity": 2,
  "unit_price": "21.62",
  "total": "43.24",
  "currency": "USD",
  "fields": {},
  "delivery": null,
  "failure": null,
  "created_at": "2026-10-24T09:12:44Z",
  "updated_at": "2026-10-24T09:12:47Z",
  "completed_at": null
}

Before charging, we check the live price, stock and availability. If any of them fails you get an error and nothing is charged.

Order lifecycle

statusFinalMeaning
processingnoBeing fulfilled. Usually seconds; some top-ups take a few minutes.
completedyesDone. Codes are in delivery.codes; for top-ups the account was credited.
failedyesCould not be fulfilled. The total was returned to your balance. See failure.code.
on_holdnoUnder manual review by our team (rare). It will move to completed or refunded — no action needed.
refundedyesRefunded to your balance by our team.

A completed order:

{
  "id": "ord_7kq2m9xw4hz8n3pd5t",
  "reference": "INV-10045",
  "status": "completed",
  "status_message": "The order is complete.",
  "product": {
    "code": "STEAM-GLOBAL-20-USD",
    "name": "Steam Wallet (Global) — 20 USD"
  },
  "quantity": 2,
  "unit_price": "21.62",
  "total": "43.24",
  "currency": "USD",
  "fields": {},
  "delivery": {
    "type": "code",
    "codes": [
      "XXXXX-XXXXX-XXXXX",
      "YYYYY-YYYYY-YYYYY"
    ],
    "message": null
  },
  "failure": null,
  "created_at": "2026-10-24T09:12:44Z",
  "updated_at": "2026-10-24T09:12:47Z",
  "completed_at": "2026-10-24T09:12:47Z"
}

Failure codes

When an order ends as failed, failure.code says why. The amount is always back on your balance.

failure.codeMeaningWhat to do
invalid_account_idThe game account ID (or server) was rejected.Fix the fields and place a new order.
out_of_stockStock ran out before the order was fulfilled.Retry later.
product_unavailableThe product became unavailable while fulfilling.Offer another product.
maintenanceOrdering was paused for a short maintenance.Retry in a few minutes.
fulfillment_failedThe order could not be fulfilled.Retry once; contact us if it repeats.
refundedThe order was refunded by our team.

Retrieve orders

GET/orders/{id}
GET/orders/reference/{reference}
GET/orders

List filters: status, product, reference, from, to (ISO dates), page, per_page. Newest first.

Polling: check every 2–5 seconds while processing; stop after a final status. Webhooks save you the polling.

Transactions

GET/transactions

Every balance movement, newest first, with the balance after it. Types: deposit, purchase, refund, adjustment, withdrawal. Filters: type, from, to.

{"data": [
  {"id": "txn_k2m8q4w7x3z9n5p6r1", "type": "purchase", "amount": "-9.17",
   "balance_after": "1241.23", "currency": "USD", "order_id": "ord_7kq2m9xw4hz8n3pd5t",
   "description": "PUBGM-660-UC × 1", "created_at": "2026-10-24T09:12:44Z"}
], "pagination": {"page": 1, "per_page": 50, "total": 1, "total_pages": 1}}

Webhooks

Set a webhook URL on the Webhooks page of the dashboard (or per order with callback_url). Whenever an order changes status we POST an order.updated event:

POST https://your-server.example/oq/webhook
Content-Type: application/json
X-OQ-Event: order.updated
X-OQ-Delivery: evt_9w3k7m2q8x4z6n5p1r
X-OQ-Signature: t=1792833167,v1=5f1c…e9a2

{
  "id": "evt_9w3k7m2q8x4z6n5p1r",
  "type": "order.updated",
  "created_at": "2026-10-24T09:12:47Z",
  "data": {
    "order": {
      "id": "ord_7kq2m9xw4hz8n3pd5t",
      "reference": "INV-10045",
      "status": "completed",
      "status_message": "The order is complete.",
      "product": {
        "code": "STEAM-GLOBAL-20-USD",
        "name": "Steam Wallet (Global) — 20 USD"
      },
      "quantity": 2,
      "unit_price": "21.62",
      "total": "43.24",
      "currency": "USD",
      "fields": {},
      "delivery": {
        "type": "code",
        "codes": [
          "XXXXX-XXXXX-XXXXX",
          "YYYYY-YYYYY-YYYYY"
        ],
        "message": null
      },
      "failure": null,
      "created_at": "2026-10-24T09:12:44Z",
      "updated_at": "2026-10-24T09:12:47Z",
      "completed_at": "2026-10-24T09:12:47Z"
    }
  }
}
  • Reply with any 2xx within 10 seconds. Otherwise we retry after 1 min, 5 min, 30 min, 2 h and 6 h.
  • Events can arrive more than once and out of order. Use data.order.status and updated_at, and ignore events for orders already final in your system.
  • The X-OQ-Delivery ID is unique per event — use it to drop duplicates.

Verifying signatures

Every webhook is signed with your signing secret (whsec_…, on the Webhooks page). X-OQ-Signature is t=<unix time>,v1=<hex> where hex = HMAC-SHA256(secret, "<t>.<raw request body>"). Reject the request if the signature does not match or t is older than 5 minutes.

import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t, sig = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or abs(time.time() - int(t)) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)
Use the raw body. Verify the bytes exactly as received — parsing and re-serialising the JSON changes them and the signature will not match.

Best practices

  • Always send a reference. On a timeout, retry with the same reference or look the order up by it.
  • Quote the price with /quote right before checkout and send it as max_unit_price.
  • Cache GET /products for a few minutes instead of calling it before every order.
  • Validate player IDs with /validate where supported, and copy fields keys exactly.
  • Treat 503 as temporary — retry later; nothing was charged.
  • Use a separate key per server, an IP allowlist and a daily limit for production keys.
  • Keep a timeout of at least 30 seconds on POST /orders.

OpenAPI

The full machine-readable specification (OpenAPI 3.1) for Postman, Insomnia or code generators. The interactive version is on the API reference page.

Specification Open
https://corporative.oyunqurdu.az/api/v1/openapi.json

Changelog

v1.0.0First release: products, live prices, orders, balance, transactions, webhooks, player ID validation, structured errors, maintenance status, key restrictions (IPs, websites, daily limit, read-only).