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.
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"
import os, time, requests
API = "https://corporative.oyunqurdu.az/api/v1"
s = requests.Session()
s.headers["Authorization"] = f"Bearer {os.environ['OQ_API_KEY']}"
order = s.post(f"{API}/orders", json={
"product": "PUBGM-660-UC",
"fields": {"player_id": "5123456789"},
"reference": "INV-10045", # your own unique ID
}, timeout=30).json()
while order["status"] == "processing":
time.sleep(3)
order = s.get(f"{API}/orders/{order['id']}", timeout=30).json()
print(order["status"], order["delivery"])
<?php
$api = "https://corporative.oyunqurdu.az/api/v1";
$ch = curl_init("$api/orders");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("OQ_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"product" => "PUBGM-660-UC",
"fields" => ["player_id" => "5123456789"],
"reference" => "INV-10045",
]),
]);
$order = json_decode(curl_exec($ch), true);
echo $order["id"], " ", $order["status"];
const API = "https://corporative.oyunqurdu.az/api/v1";
const headers = {
"Authorization": `Bearer ${process.env.OQ_API_KEY}`,
"Content-Type": "application/json",
};
const res = await fetch(`${API}/orders`, {
method: "POST",
headers,
body: JSON.stringify({
product: "PUBGM-660-UC",
fields: { player_id: "5123456789" },
reference: "INV-10045",
}),
});
const order = await res.json();
console.log(order.id, order.status);
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.
// 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"); }
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
pageandper_page(max 100) and returndatapluspagination. - Every response carries
X-Request-Id. Include it when you contact us about a request. - Trailing slashes are optional:
/ordersand/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).
| code | HTTP | Retry? | Meaning | What to do |
|---|---|---|---|---|
| unauthorized | 401 | no | No API key was sent. | Send `Authorization: Bearer <key>` (or `X-API-Key`). |
| malformed_api_key | 401 | no | The value is not an API key (keys look like `oqc_…_…`). | Copy the whole key; check for spaces or quotes. |
| invalid_api_key | 401 | no | The API key does not exist. | Check the key or create a new one in the dashboard. |
| api_key_revoked | 401 | no | The key was revoked. | Create a new key in the dashboard. |
| account_disabled | 403 | no | The account is disabled. | Contact us. |
| ip_not_allowed | 403 | 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_allowed | 403 | 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_only | 403 | no | This key can read but cannot place orders. | Use a key with ordering allowed, or turn it on in the dashboard. |
| invalid_json | 400 | no | The body is not a valid JSON object. | Send a JSON object with `Content-Type: application/json`. |
| unsupported_media_type | 415 | no | The body is not JSON. | Set `Content-Type: application/json`. |
| payload_too_large | 413 | no | The request body is larger than 32 KB. | Send a smaller body. |
| validation_error | 422 | no | One or more body parameters are missing or invalid. | `details` lists each parameter with a `code` and `message`. |
| invalid_parameter | 422 | no | A query parameter is invalid. | `details` names the parameter and the allowed values. |
| invalid_fields | 422 | 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_found | 404 | no | No such endpoint. | Check the path and the `/v1` prefix. |
| method_not_allowed | 405 | no | The HTTP method is not supported here. | See the `Allow` header. |
| product_not_found | 404 | no | No product with this code. | List products with `GET /products`. |
| product_discontinued | 410 | no | The product is no longer sold. | Remove it from your catalogue; pick another product. |
| product_unavailable | 409 | yes | The product is temporarily unavailable. | Try again later or offer another product. |
| out_of_stock | 409 | yes | Not enough stock for the requested quantity. | `details.available` is what is left. Lower the quantity or retry later. |
| quantity_out_of_range | 422 | no | Quantity is outside the product's limits. | `details` has `min` and `max`. |
| not_supported | 422 | no | The operation is not available for this product. | |
| insufficient_balance | 402 | no | Your balance is lower than the order total. | Top up. `details` has `balance`, `required` and `shortfall`. |
| order_limit_exceeded | 422 | 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_exceeded | 422 | no | This key has reached its daily spending limit. | Wait until 00:00 UTC or raise the key's limit in the dashboard. |
| price_changed | 409 | yes | The current price is higher than your `max_unit_price`. | Re-read the price (`details.unit_price`) and retry. |
| duplicate_reference | 409 | no | This `reference` was already used with different parameters. | Use a new reference. `details.order_id` is the existing order. |
| order_not_found | 404 | no | No order with this ID or reference. | Check the ID. |
| rate_limited | 429 | yes | Too many requests in this minute. | Wait `Retry-After` seconds. |
| maintenance | 503 | yes | Ordering is paused for a short maintenance. | Retry after `Retry-After` seconds. Your balance was not charged. Browsing still works. |
| service_unavailable | 503 | yes | A part of the service is temporarily unavailable. | Retry later. Nothing was charged. |
| internal_error | 500 | 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"
}POST /orders returns any error, your balance was not touched. Money moves only when an order is created (201)."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 /ordersreturns503 maintenancewith aRetry-Afterheader (seconds). Nothing is charged.- Everything else — products, prices, balance, orders, transactions — keeps working.
GET /pingandGET /accountshow it inservice:
{"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 resetsOver 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 with200andIdempotent-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
Your company, login, balance, rate limit, webhook settings and service status.
{"balance": "1250.40", "currency": "USD"}The balance is topped up by our team after you transfer funds. Every movement is listed under transactions.
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
All products with your prices. Looking up codes needs no market call — it is instant. Filters:
| category | Category code, e.g. free-fire |
| region | GLOBAL, EU, TR, US, CIS, RU, ID |
| type | topup or code |
| q | Search in name and code |
| in_stock | true — only products that can be ordered now |
| live | true — re-price the category from the market now (needs category) |
| page, per_page | Pagination (per_page ≤ 100) |
{
"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."
}| code | Stable product code. It never changes — store it in your system. |
| type | topup: credited to the game account in fields. code: you receive gift card codes. |
| price | Your unit price, refreshed from the market at least every 15 minutes (price_updated_at). For the price right now use the quote endpoint. |
| stock | Units available for codes. null means not limited by stock (top-ups). |
| min_quantity / max_quantity | Allowed quantity per order. |
| fields | What you must send in fields when ordering — e.g. player_id, server_id. Empty for codes. |
Live prices
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 quotedunit_price) with the order: if the price went up in between, the order is refused with409 price_changedand nothing is charged. - For a whole category at once:
GET /products?category=pubg-mobile&live=true.
Validate a player ID
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
| Field | Required | Description |
|---|---|---|
| product | yes | Product code. |
| quantity | no | Default 1. Must be within the product's min/max. |
| fields | for top-ups | Object with every key from the product's fields. Unknown keys are rejected. |
| reference | recommended | Your unique ID (1–64 chars: letters, digits, _ . : -). Makes the request safe to retry. |
| max_unit_price | no | Refuse the order with 409 price_changed if the unit price is now higher. |
| callback_url | no | https 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:
{
"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
| status | Final | Meaning |
|---|---|---|
| processing | no | Being fulfilled. Usually seconds; some top-ups take a few minutes. |
| completed | yes | Done. Codes are in delivery.codes; for top-ups the account was credited. |
| failed | yes | Could not be fulfilled. The total was returned to your balance. See failure.code. |
| on_hold | no | Under manual review by our team (rare). It will move to completed or refunded — no action needed. |
| refunded | yes | Refunded 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.code | Meaning | What to do |
|---|---|---|
| invalid_account_id | The game account ID (or server) was rejected. | Fix the fields and place a new order. |
| out_of_stock | Stock ran out before the order was fulfilled. | Retry later. |
| product_unavailable | The product became unavailable while fulfilling. | Offer another product. |
| maintenance | Ordering was paused for a short maintenance. | Retry in a few minutes. |
| fulfillment_failed | The order could not be fulfilled. | Retry once; contact us if it repeats. |
| refunded | The order was refunded by our team. |
Retrieve 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
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
2xxwithin 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.statusandupdated_at, and ignore events for orders already final in your system. - The
X-OQ-DeliveryID 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)
function oq_verify(string $rawBody, string $header, string $secret): bool {
parse_str(str_replace(",", "&", $header), $p);
if (!isset($p["t"], $p["v1"]) || abs(time() - (int)$p["t"]) > 300) return false;
$expected = hash_hmac("sha256", $p["t"] . "." . $rawBody, $secret);
return hash_equals($expected, $p["v1"]);
}
// $ok = oq_verify(file_get_contents("php://input"), $_SERVER["HTTP_X_OQ_SIGNATURE"], $secret);
import crypto from "node:crypto";
export function verify(rawBody, header, secret) {
const p = Object.fromEntries(header.split(",").map(x => x.split("=")));
if (!p.t || Math.abs(Date.now() / 1000 - Number(p.t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret)
.update(`${p.t}.`).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(p.v1 || ""));
}
// Express: app.post("/oq/webhook", express.raw({type: "application/json"}), …)
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
/quoteright before checkout and send it asmax_unit_price. - Cache
GET /productsfor a few minutes instead of calling it before every order. - Validate player IDs with
/validatewhere supported, and copyfieldskeys exactly. - Treat
503as 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.
https://corporative.oyunqurdu.az/api/v1/openapi.json
Changelog
| v1.0.0 | First release: products, live prices, orders, balance, transactions, webhooks, player ID validation, structured errors, maintenance status, key restrictions (IPs, websites, daily limit, read-only). |