Create Order
Place a new top-up order.
Submit a new top-up order. Orders are processed asynchronously. This endpoint guarantees sub-50ms ingestion latency and atomic idempotency.
Endpoint
Request Headers
Headers
| Parameter | Type | Required | Description |
|---|---|---|---|
Content-Type | String | Yes | Must be application/json. |
X-Api-Key | String | Yes | Your API Key. |
X-Timestamp | String | Yes | Current Unix timestamp in ms. |
X-Signature | String | Yes | HMAC-SHA256 of JSON body + timestamp. |
Request Body
{
"ref_id": "R-12345",
"product_code": "ALGAN-MLBB-86",
"target": "12345678",
"zone": "1234",
"price": 25000
}Body Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
ref_id | String | Yes | Your unique reference ID (max 64 chars). Used for idempotency. |
product_code | String | Yes | Algan product code from GET /products (max 128 chars). |
target | String | Yes | Game User ID or phone number (max 128 chars). |
zone | String | No | Game Zone/Server ID if required (max 64 chars). For games with a region AND a server, join them with a pipe: "Europe|Manibus(Novice)-00558". |
price | Number | String | No | Optional safety check. Accepts a number (25000) or its numeric-string form ("25000"). If provided, must equal the exact price from GET /products or the order is rejected (PRICE_NOT_MATCH); this protects you from stale prices. To skip the check, omit the field entirely (charged at the current price). Sending it as null or "" is a validation error, not an omission. |
price is optional (recommended for safety)
You do not have to send price. It is an optional confirmation token, not an authority; Algan always charges the current server price for your tier, whether or not you send it.
- Send
price(recommended): the order is rejected withPRICE_NOT_MATCHif your value doesn’t match the current price. This guarantees you never get charged more than the price you showed your customer. Pair it withGET /productsto fetch the live price first. - Omit
price: the order proceeds at the current price with no confirmation step. Simpler to integrate, but if the price changed since you last synced, you absorb the difference.
Existing integrations that send price are unaffected; behaviour is identical. The response always echoes the actual price charged so you can reconcile.
Format: price may be sent as a JSON number (25000) or a numeric string ("25000"); many platforms serialize numbers as strings, and both are accepted. To waive the check, leave the field out of the body entirely; sending null or "" is rejected as a validation error (it is not treated as omission).
Signature for POST
For POST requests, the HMAC message is JSON.stringify(body) + timestamp. Make sure you sign the exact JSON string you send in the body.
Mapping Form Fields to target & zone
The form array returned by GET /categories tells you what to collect from your customer and how to validate it. Each field carries a role that maps it to this endpoint; no guessing by field name:
- the field with
role: "account_id"→ send its value astarget - the field with
role: "server"→ send its value aszone - two or more
role: "server"fields (a region and a server, e.g. Once Human) → join their values into the singlezonestring with a pipe|, in the form’s fieldorder(region first). See the multi-part callout below.
The order body accepts exactly those two routing fields (plus ref_id, product_code, price). A server field may be type: "select"; send the chosen option’s value.
target is always required
Whatever single account identifier the game needs (uid, user_id, player_id, userId, playerId, riotId, etc.), collapse it into the target string. If you omit target you will get 400 target is required (string), and because target is part of the signed body, a malformed body also surfaces as INVALID_SIGNATURE. Only ref_id, product_code, target, zone, and price are accepted; any other field is rejected as Unknown field.
| Game shape | Example form fields | How to send it |
|---|---|---|
| Single identifier (Free Fire, Honor of Kings, etc.) | user_id → 123456789 |
target: "123456789", no zone. |
| Identifier + server/zone (Mobile Legends, etc.) | user_id → 12345678, zone_id → 1234 |
target: "12345678", zone: "1234". The server/zone value is the only thing that goes in zone. |
| Identifier + region + server (Once Human, etc.) | userId → 158149791, regionId (select) → Europe, serverId → Manibus(Novice)-00558 |
target: "158149791", zone: "Europe|Manibus(Novice)-00558". Join region and server into zone with a pipe |, region first. |
| Composite identifier (Valorant Riot ID, etc.) | riotId + tagline, placeholder VALOUSERNAME#1234 |
Join into one string → target: "VALOUSERNAME#1234". Do not split the tagline into zone. |
Voucher (type: "voucher", e.g. STEAM VOUCHER PHP) |
none from us; build your own form | The code is delivered back to you, so target is just a delivery/reference label. Pass any value you control, e.g. your customer’s email or your own reseller email as a placeholder → target: "[email protected]". No zone. |
When is zone required?
Send zone only when the game’s form includes a field with role: "server" (Mobile Legends, Genshin, etc.); that field’s value is the zone. If no field has role: "server", omit zone entirely; sending it for a single-identifier or voucher product has no effect.
Games with a region AND a server (multi-part zone)
A few games (Once Human is the current example) collect two server-type values, a region and a server name, so their form returns two fields with role: "server". The order body still has a single zone slot, so join the two values into zone with a pipe |, in the form’s field order (region first): zone: "Europe|Manibus(Novice)-00558". Send the region field’s option value (not its label). zone accepts up to 64 characters, which covers every current combination.
Response
{
"success": true,
"data": {
"order_id": "d7e8f9a0-b1c2-3d4e-5f6a-7b8c9d0e1f2a",
"order_code": "AGN-D7E8F9A0B1C2",
"ref_id": "R-12345",
"product_name": "Mobile Legends 86 Diamonds",
"price": 25000,
"status": "processing",
"message": "Order received and is being processed"
}
}order_code: the short form of order_id
order_code is a NEW field, added beside order_id and never in place of it. It is a short,
human-quotable rendering of the same order (AGN- + 12 characters) meant for support chats,
invoices and phone calls where a 36-character UUID is unwieldy. order_id keeps its exact
value, type and meaning; nothing was renamed, retyped or removed. Do not use order_code as
an idempotency key: ref_id remains the only key we deduplicate on.
Which one should you store? Prefer order_code.
If our order_id is too long for your database column, your invoice layout or your support
UI, store order_code instead. This is the most common integration question we get, so
here is the full answer:
- They are the same order.
order_codeis derived fromorder_id(it isAGN-plus the first 12 hex characters of the UUID, upper-cased). It is not a second identifier and there is no lookup table to keep in sync. - It never changes. The format is frozen. An order that returns
AGN-D7E8F9A0B1C2today returns exactly that forever, on every response and every callback. - It is safe to display. It is not a credential, it opens nothing, and it reveals nothing about which upstream supplier fulfilled the order. Show it to your own customers freely.
order_idis not going anywhere. It stays in every response with the same name, type and value, so keeping it costs you nothing and dropping it costs you nothing either. Store one, the other, or both.
The one thing order_code is not is an idempotency key. Keep sending your own ref_id on
POST /order and keep matching callbacks on ref_id, exactly as you do now.
Example cURL
API_KEY="algan_live_..."
API_SECRET="sk_live_..."
TIMESTAMP=$(python3 -c "import time; print(int(time.time() * 1000))")
BODY='{"ref_id":"R-12345","product_code":"ALGAN-MLBB-86","target":"12345678","zone":"1234","price":25000}'
SIGNATURE=$(echo -n "${BODY}${TIMESTAMP}" | \
openssl dgst -sha256 -hmac "${API_SECRET}" | awk '{print $2}')
curl -s -X POST https://algan.id/api/v1/order \
-H "Content-Type: application/json" \
-H "X-Api-Key: ${API_KEY}" \
-H "X-Timestamp: ${TIMESTAMP}" \
-H "X-Signature: ${SIGNATURE}" \
-d "${BODY}"
Example (Node.js)
const crypto = require('crypto');
const API_KEY = 'algan_live_...';
const API_SECRET = 'sk_live_...';
const timestamp = Date.now().toString();
const body = {
ref_id: 'R-12345',
product_code: 'ALGAN-MLBB-86',
target: '12345678',
zone: '1234',
price: 25000,
};
const payloadString = JSON.stringify(body);
const signature = crypto.createHmac('sha256', API_SECRET)
.update(payloadString + timestamp).digest('hex');
const res = await fetch('https://algan.id/api/v1/order', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': API_KEY,
'X-Timestamp': timestamp,
'X-Signature': signature,
},
body: payloadString,
});
const data = await res.json();
Idempotency
Duplicate Protection
If you send the exact same ref_id twice, the second request will be rejected with a 409 DUPLICATE_REF_ID error. No balance will be deducted twice. This is your safeguard against network retries.
Pre-order Checker Refusals
For games with the ID checker enabled, the order is validated before any charge. When the checker has a definitive answer that the order cannot be delivered, the request is refused instantly instead of being accepted and failing after dispatch:
{
"success": false,
"error": "PURCHASE_LIMIT",
"message": "The account has already used its purchase allowance for this product.",
"request_id": "a1b2c3d4-e5f6-..."
}Three refusal codes exist: PURCHASE_LIMIT (a limit-gated product — first top-up tier or
weekly/monthly pass — the account has already consumed), REGION_LOCK (the account’s
verified region cannot receive this product), and INVALID_ID (the checker explicitly
confirmed the account does not exist).
A refusal creates NO order
Nothing is inserted, no balance moves, no callback fires, and GET /order-status for that
ref_id returns not-found. A refusal is not a failed order — it is the failure you would
have received after dispatch, delivered before payment instead.
A refused ref_id is NOT consumed — you may reuse it
ref_id idempotency is claimed only when an order row is created. Because a refusal
creates no order, the same ref_id stays free: retrying the identical request is refused
again (deterministically, at no cost), and retrying the SAME ref_id with a corrected
target/product_code proceeds normally. This is different from a failed order,
which does exist — its ref_id is consumed and a reuse returns 409 DUPLICATE_REF_ID.
Do not blind-retry a refusal
All three refusal codes describe a fact about the target account, not a transient
condition. Retrying unchanged just repeats the refusal. Surface the error code to your
flow (or call POST /validation first — it exposes the same verdicts before
you order). If the checker cannot reach a definitive answer, your order proceeds
normally — an outage on our side never blocks you.
Error Responses
Possible Errors
| Parameter | Type | Required | Description |
|---|---|---|---|
DUPLICATE_REF_ID | 409 | No | An order with this ref_id already exists. |
INSUFFICIENT_BALANCE | 402 | No | Not enough balance. |
PRODUCT_NOT_FOUND | 404 | No | Invalid or inactive product_code. |
PRICE_NOT_MATCH | 409 | No | Price changed since you fetched products. |
PURCHASE_LIMIT | 400 | No | Refused before charge: the account already used its allowance for this limit-gated product. No order created; ref_id reusable. |
REGION_LOCK | 400 | No | Refused before charge: the account's verified region cannot receive this product. No order created; ref_id reusable. |
INVALID_ID | 400 | No | Refused before charge: the checker confirmed the account does not exist. No order created; ref_id reusable. |