Distributor API
A host-to-host interface for purchasing digital products automatically from your own system. Intended for distributor partners who already hold a balance at AM08SHOP.
Base URL
Production https://api.am08shop.com/v1 Sandbox — not yet available, the name is reserved https://sandbox-api.am08shop.com/v1
Every endpoint uses the POST method and exchanges JSON — including read-only endpoints such as the balance check. This is deliberate: the request signature includes a digest of the body, so the rules stay uniform and there are no special cases that could be applied incorrectly.
Prerequisites
The following four conditions must be met before your first request is accepted.
| Requirement | Details |
|---|---|
| Distributor role | Your account must have the Distributor role. The prices that apply in the API are Distributor-tier prices, not storefront prices. |
| Balance | Orders can only be paid for with your balance. There is no other payment method in the API — top up your balance first through the member area. |
| PIN | Your account must already have a PIN. The PIN is sent with every order request. |
| IP whitelist | Requests are only accepted from IP addresses you have registered on the API Credentials page. IPs outside the list are rejected before the signature is even checked. While the list is empty, every request is rejected — see Configuring the API Credentials page. |
Credentials, the IP whitelist and callback URLs are managed by you through the API Credentials menu in your member area. Changing any of them requires your PIN.
Credentials
You hold two values with different roles. It is important to tell them apart: one may be seen by others, the other must never be.
States who is calling. It is sent as-is in the X-Client-Id header on every request, so its value is visible by design — its role is like a username, not a password. It is safe to appear in your own logs.
Used only to compute X-Signature. Its value is never included in a request — only the result of the computation is sent. Its role is like a password: anyone who holds it can impersonate you.
This is why a signature is safer than simply sending a secret key in a header: even if a request is intercepted, the eavesdropper only obtains the API Key and one signature that is valid for that request only — not a key that can be used to forge new requests.
Getting and replacing credentials
Both are issued automatically the first time you open the API Credentials menu in the member area. You do not need to request anything from us: as soon as your account has the Distributor role, that menu appears, and your first visit there issues your keys.
The Secret Key is shown in full only at the moment it is issued. Once you leave that page, its value can no longer be displayed — it is not merely hidden, it is never sent again. Copy and store it before closing the tab. If you miss it, press Regenerate to issue a new one.
Regenerating makes the old Secret Key invalid immediately, so any integration still using it will fail with rc 11. Prepare the replacement on your side before pressing the button.
Configuring the API Credentials page
Besides showing your keys, that page is where you set where requests may come from and where we send callbacks. Saving any change here requires your PIN.
IP whitelist
The list of your servers' IP addresses. Requests from addresses outside the list are rejected first, before the signature is even checked.
This is not an oversight; it is the gate itself. Newly issued credentials always start with an empty list, so no request can get in until you fill it — even a key that leaks before you have used it is useless to anyone. The side effect is useful: emptying the list is the quickest way to pause your whole integration without deleting anything.
| Rule | Details |
|---|---|
| Exact addresses | Enter IPs one by one. CIDR ranges (10.0.0.0/8) and hostnames (my-server.com) are rejected — a hostname can be repointed to another address without our knowledge, which would stop the list from being a guarantee. |
| Separator | Commas or newlines, and you may mix them. 1.2.3.4, 5.6.7.8 and one-per-line are equivalent. |
| Maximum 20 addresses | This list is scanned on every API request you make, so its length is capped to keep it from becoming a cost you pay continuously. |
| IPv6 is normalized | IPv6 addresses are stored in canonical form. 2606:4700::ABCD will be shown back as 2606:4700::abcd, and ::ffff:1.2.3.4 becomes 1.2.3.4. These are the same addresses — not a change, and precisely what makes them match when your request arrives. |
Callback URL
Where we send notifications. Leave it empty if you do not use callbacks and prefer to fetch status through check status.
| Rule | Details |
|---|---|
https:// required | The callback body contains the destination number, the SN and your remaining balance. http:// is rejected so that this data never travels in plain text. |
| Public address | Non-public addresses — localhost, 127.0.0.1, private ranges, cloud metadata addresses — are rejected. The check is repeated every time we contact you, not only when you save. |
| No redirects | 3xx replies are not followed and count as a failure. Point directly at the target endpoint. |
The field is already available and you may fill it in now, but nothing is sent to it yet. The product.changed callback will start working once the feature is released; until then, fetch price changes through the product list using the updated_since parameter.
Authentication
Every request carries three headers. There is no token to store or refresh — the signature is recomputed for each request.
| Header | Value |
|---|---|
| X-Client-Id | Your API Key — see Credentials. |
| X-Request-Time | The request time in ISO 8601 format, for example 2026-08-05T09:14:22+07:00 or 2026-08-05T02:14:22Z. |
| X-Signature | HMAC-SHA256 as lowercase hexadecimal, computed with the Secret Key. How to build it is described below. |
Building the signature
- Compose the JSON body, then strip all spaces and newlines so it becomes a single compact line.
- Compute the
MD5of that body string. - Join four parts separated by colons: the method, the path without the leading slash, the MD5 result, and
X-Request-Time. - Compute
HMAC-SHA256over that string using your Secret Key as the key.
// 1 — compact the body {"service_code":"ML10","reference_number":"INV-0001",...} // 2 — md5 of the body above ca06c494d590574127263feeed50bae5 // 3 — the string to be signed POST:v1/order:ca06c494d590574127263feeed50bae5:2026-08-05T09:14:22+07:00 // 4 — final result, goes into the X-Signature header hmac_sha256(secret, string_above) // hex, lowercase
X-Request-Time is rejected if it differs by more than 5 minutes from our server clock. Make sure your server clock is NTP-synchronized — a drifting clock is the most common and most confusing cause of failed integrations, because the signature itself is correct.
Each signature can be used only once. To resend (for example after a timeout), recompute X-Request-Time and the signature.
The Secret Key is only used to compute the signature and is never sent in a request. Do not put it in client-side code, public repositories or logs. If it leaks, regenerate it from the API page — signatures made with the old key stop working immediately.
The X-Request-Time value in the header must be exactly the same as the one you put into the signing string. Compute it once, keep it in a variable, and use it in both places. Generating it twice separately is the hardest-to-trace cause of rc 11, because even a one-second difference produces a different signature.
Code examples
A signing function together with an example call to the order endpoint. All examples produce identical signatures for the same input.
// Node.js 18+ — no external dependencies const crypto = require('crypto'); const BASE_URL = 'https://api.am08shop.com'; const API_KEY = process.env.PK_API_KEY; // pk_live_… const SECRET_KEY = process.env.PK_SECRET_KEY; // sk_live_… never hardcode this function buatSignature(pathname, payload, requestTime) { const md5 = crypto.createHash('md5').update(payload).digest('hex'); const raw = `POST:${pathname}:${md5}:${requestTime}`; return crypto.createHmac('sha256', SECRET_KEY).update(raw).digest('hex'); } async function panggil(pathname, body) { // JSON.stringify already produces a compact string with no spaces/newlines. // The signed payload MUST be the very same string that is sent. const payload = JSON.stringify(body); const requestTime = new Date().toISOString(); const res = await fetch(`${BASE_URL}/${pathname}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Client-Id': API_KEY, 'X-Request-Time': requestTime, 'X-Signature': buatSignature(pathname, payload, requestTime), }, body: payload, }); return res.json(); } // Example: create an order panggil('v1/order', { service_code: 'ML10', reference_number: 'INV-0001', inputs: { user_id: '12345678', zone_id: '2201' }, price: 3000, pin: process.env.PK_PIN, }).then(console.log);
// TypeScript — Node.js 18+ import { createHash, createHmac } from 'node:crypto'; const BASE_URL = 'https://api.am08shop.com'; const API_KEY = process.env.PK_API_KEY!; const SECRET_KEY = process.env.PK_SECRET_KEY!; function buatSignature(pathname: string, payload: string, requestTime: string): string { const md5 = createHash('md5').update(payload).digest('hex'); const raw = `POST:${pathname}:${md5}:${requestTime}`; return createHmac('sha256', SECRET_KEY).update(raw).digest('hex'); } interface ApiResponse<T> { rc: string; message: string; data?: T; } async function panggil<T>(pathname: string, body: unknown): Promise<ApiResponse<T>> { const payload = JSON.stringify(body); const requestTime = new Date().toISOString(); const res = await fetch(`${BASE_URL}/${pathname}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Client-Id': API_KEY, 'X-Request-Time': requestTime, 'X-Signature': buatSignature(pathname, payload, requestTime), }, body: payload, }); return res.json() as Promise<ApiResponse<T>>; } interface Order { order_id: string; reference_number: string; status: 'PROCESSING' | 'SUCCESS' | 'FAILED' | 'REFUNDED'; sn: string; total: number; } const hasil = await panggil<Order>('v1/order', { service_code: 'ML10', reference_number: 'INV-0001', inputs: { user_id: '12345678', zone_id: '2201' }, price: 3000, pin: process.env.PK_PIN!, });
<?php const BASE_URL = 'https://api.am08shop.com'; function buatSignature(string $pathname, string $payload, string $requestTime, string $secretKey): string { $md5 = md5($payload); $raw = "POST:{$pathname}:{$md5}:{$requestTime}"; return hash_hmac('sha256', $raw, $secretKey); } function panggil(string $pathname, array $body): array { $apiKey = getenv('PK_API_KEY'); $secretKey = getenv('PK_SECRET_KEY'); // JSON_UNESCAPED_SLASHES keeps the payload from changing shape; // this string is both signed AND sent. $payload = json_encode($body, JSON_UNESCAPED_SLASHES); $requestTime = date('c'); // example: 2026-08-05T09:14:22+07:00 $ch = curl_init(BASE_URL . '/' . $pathname); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-Client-Id: ' . $apiKey, 'X-Request-Time: ' . $requestTime, 'X-Signature: ' . buatSignature($pathname, $payload, $requestTime, $secretKey), ], ]); $res = curl_exec($ch); curl_close($ch); return json_decode($res, true); } // Example: create an order $hasil = panggil('v1/order', [ 'service_code' => 'ML10', 'reference_number' => 'INV-0001', 'inputs' => ['user_id' => '12345678', 'zone_id' => '2201'], 'price' => 3000, 'pin' => getenv('PK_PIN'), ]); print_r($hasil);
package main import ( "bytes" "crypto/hmac" "crypto/md5" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "time" ) const baseURL = "https://api.am08shop.com" func buatSignature(pathname, payload, requestTime, secretKey string) string { sum := md5.Sum([]byte(payload)) raw := fmt.Sprintf("POST:%s:%s:%s", pathname, hex.EncodeToString(sum[:]), requestTime) mac := hmac.New(sha256.New, []byte(secretKey)) mac.Write([]byte(raw)) return hex.EncodeToString(mac.Sum(nil)) } func panggil(pathname string, body any) (map[string]any, error) { // json.Marshal produces a compact string; this string is // both signed AND sent as the body. payload, err := json.Marshal(body) if err != nil { return nil, err } requestTime := time.Now().Format(time.RFC3339) // 2026-08-05T09:14:22+07:00 req, err := http.NewRequest("POST", baseURL+"/"+pathname, bytes.NewReader(payload)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Client-Id", os.Getenv("PK_API_KEY")) req.Header.Set("X-Request-Time", requestTime) req.Header.Set("X-Signature", buatSignature(pathname, string(payload), requestTime, os.Getenv("PK_SECRET_KEY"))) res, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer res.Body.Close() raw, _ := io.ReadAll(res.Body) var out map[string]any return out, json.Unmarshal(raw, &out) } func main() { hasil, err := panggil("v1/order", map[string]any{ "service_code": "ML10", "reference_number": "INV-0001", "inputs": map[string]string{"user_id": "12345678", "zone_id": "2201"}, "price": 3000, "pin": os.Getenv("PK_PIN"), }) if err != nil { panic(err) } fmt.Println(hasil) }
Sign exactly the same body string that you send — do not re-serialize the JSON just for signing. Key order, whitespace and character escaping can differ between calls, and a single differing character makes the MD5 — and therefore the whole signature — mismatch. In all the examples above, a single payload variable is used for both.
Verifying the callback signature
The computation is the same; only pathname is replaced with the path of your own callback endpoint.
// Node.js — verify an incoming callback const diterima = req.headers['x-signature']; const harusnya = buatSignature('webhook/am08shop', rawBody, req.headers['x-request-time']); // Compare with timingSafeEqual, not === , so the comparison time // does not leak information about the correct signature. const cocok = diterima?.length === harusnya.length && crypto.timingSafeEqual(Buffer.from(diterima), Buffer.from(harusnya)); if (!cocok) return res.status(401).end();
rawBody must be the raw body as received, not the result of parsing and then re-stringifying it. In Express, use express.raw() or capture the raw body through the verify option before the JSON is parsed.
Response codes
Every response carries rc and message. The HTTP status is always 200 as long as the request was received and authenticated — read the business outcome from rc, not from the HTTP status.
| rc | Meaning | Suggested action |
|---|---|---|
| 00 | Success | — |
| 01 | Transaction is being processed | Wait for the callback. Do not resend the order. |
| 02 | Transaction failed | Your balance is being refunded automatically. |
| 03 | Balance has been refunded | Final status. Safe to place the order again. |
| 10 | Unknown Client ID | Check the X-Client-Id header. |
| 11 | Invalid signature | Check the body compaction and the order of the signing string. |
| 12 | Request time expired | Synchronize your server clock. |
| 13 | IP not allowed | Add the IP on the API page. |
| 14 | Wrong PIN | Stop retrying. See the note below. |
| 15 | API access disabled | Contact us. Also returned for new orders while the website is under maintenance (see message); resending an existing reference_number still returns that order's status. |
| 16 | Request already received | A signature is valid only once. Resend with a new X-Request-Time and signature. |
| 20 | Product not found | Refresh your catalog. |
| 21 | Product is currently inactive | Try again later or use another product. |
| 22 | Price mismatch | Re-fetch the price from the product list. |
| 30 | Insufficient balance | Top up your balance. |
| 31 | Incomplete account data | Complete inputs according to inputs_schema. |
| 40 | Reference number already used for a different order | Use a new reference number. |
| 99 | System error | Try again in a moment. |
Sending a wrong PIN repeatedly will lock your API access for 15 minutes. This lockout applies to the API only and does not affect your PIN in the member area. If you receive rc 14, stop sending and fix your configuration — automatic retries only extend the lockout.
Balance
Returns your account's remaining balance in Rupiah.
Request
{}
The body is empty, but it must still be sent as {} and be included in the signature.
Response
{
"rc": "00",
"message": "Sukses",
"data": {
"balance": 1250000,
"currency": "IDR"
}
}
Product list
Returns the catalog with your Distributor-tier prices and the shape of the account data each product requires.
Request
| Field | Type | Details |
|---|---|---|
| updated_sinceoptional | string | ISO 8601. If provided, only products changed since that time are returned. Use it for periodic synchronization without pulling the whole catalog. |
| category_codeoptional | string | Restrict to a single category. |
Response
{
"rc": "00",
"message": "Sukses",
"data": [
{
"service_code": "ML10",
"service_name": "10 Diamonds",
"category_code": "MLBB",
"category_name": "Mobile Legends",
"price": 3000,
"status": "active",
"updated_at": "2026-08-05T09:00:00+07:00",
"inputs_schema": [
{ "key": "user_id", "label": "User ID", "type": "number", "required": true },
{ "key": "zone_id", "label": "Zone ID", "type": "number", "required": true }
]
}
]
}
inputs_schema is the contract for the inputs field when creating an order. Mobile top-up products usually have a single entry; games usually have two. Store this schema alongside your catalog so you can validate on your side before sending an order.
Create order
Request
| Field | Type | Details |
|---|---|---|
| service_coderequired | string | The product code from the product list. |
| reference_numberrequired | string | Your own reference number, unique per account. Max. 64 characters. |
| inputsrequired | object | Destination account data, keyed according to the product's inputs_schema. |
| priceoptional | number | The unit price you believe applies. If sent and it does not match, the order is rejected with rc 22. If omitted (or 0), the order is charged at the Distributor-tier price in effect at that moment. |
| pinrequired | string | Your account's 6-digit PIN. |
| qtyunused | number | No longer used. Every order always has a quantity of 1. You may still send this field — its value is ignored, so existing integrations do not need to change. To order several units, send several orders with different reference_number values. |
{
"service_code": "ML10",
"reference_number": "INV-0001",
"inputs": {
"user_id": "12345678",
"zone_id": "2201"
},
"price": 3000,
"pin": "123456"
}
Response
{
"rc": "01",
"message": "Transaksi sedang diproses",
"data": {
"order_id": "TRX-20260805-A1B2C",
"reference_number": "INV-0001",
"service_code": "ML10",
"service_name": "10 Diamonds",
"status": "PROCESSING",
"price": 3000,
"qty": 1,
"total": 3000,
"sn": "",
"balance_after": 1247000,
"created_at": "2026-08-05T09:14:22+07:00"
}
}
Resending the same reference_number does not create a second order — you receive the status of the existing order back. This also applies when your connection drops before the response is received: resend with the same body and reference_number, but a new X-Request-Time and signature — your balance will not be deducted twice. A signature is valid only once; an exact duplicate request is rejected with rc 16.
If the same reference_number is sent with different content, the request is rejected with rc 40.
Your balance is deducted when the order is accepted. If the order ends in failure, the balance is refunded automatically and the status becomes REFUNDED — you do not need to file anything.
Check status
Use this as a safety net when a callback is not received. It is not a replacement for callbacks — do not poll aggressively.
Request
Provide one of the following two fields.
| Field | Type | Details |
|---|---|---|
| reference_number | string | Your reference number. |
| order_id | string | The order number from our system. |
{ "reference_number": "INV-0001" }
Response
The shape is exactly the same as the create order response, with the latest status and sn.
Transaction status callback
Sent to your transaction callback URL whenever an order reaches a final status. We send a JSON POST with the same signature headers as your requests to us — so you can verify it with code that works identically.
{
"event": "transaction.status",
"data": {
"order_id": "TRX-20260805-A1B2C",
"reference_number": "INV-0001",
"service_code": "ML10",
"status": "SUCCESS",
"rc": "00",
"message": "Transaksi berhasil",
"sn": "SN-8842190",
"price": 3000,
"qty": 1,
"total": 3000,
"balance_after": 1247000,
"updated_at": "2026-08-05T09:15:04+07:00"
}
}
Reply with HTTP 200 to acknowledge the callback. Any response other than 200, or no response within 10 seconds, counts as a failure and will be retried every 60 seconds, up to five times. After that the callback stops and you need to fetch the status through check status.
The same callback may be delivered more than once — for example if your 200 response is lost in transit. Make sure your processing is safe to repeat, keyed on reference_number.
Product change callback
Sent to your product callback URL when a product's price or status changes. Changes are batched first for about 20 seconds, then delivered as a single callback containing an array.
A single admin action — for example changing a category's margin — can change the prices of hundreds of products at once. If each product sent its own callback, your endpoint would receive hundreds of requests in an instant. With batching, one admin action produces one callback, and you can process it in a single database transaction.
{
"event": "product.changed",
"data": [
{
"service_code": "ML10",
"price": 3100,
"status": "active",
"changed": ["price"],
"updated_at": "2026-08-05T09:20:00+07:00"
},
{
"service_code": "ML86",
"price": 21000,
"status": "inactive",
"changed": ["status"],
"updated_at": "2026-08-05T09:20:00+07:00"
}
]
}
The changed field lists what changed, so you can update only what is needed. The retry rules are the same as for the transaction callback.
Callback signature verification
Callbacks from us carry the X-Client-Id, X-Request-Time and X-Signature headers. The signed string uses your own callback path, without the leading slash.
// Your callback URL: https://your-server.com/webhook/am08shop
POST:webhook/am08shop:<md5(compacted body)>:<X-Request-Time>
Without verification, anyone who knows your callback URL can send fake notifications — for example claiming an order is SUCCESS when it is not. Compare the signature before processing the body, and reject the request if it does not match.
Transaction status
| Status | rc | Meaning |
|---|---|---|
| PROCESSING | 01 | The order has been accepted and is being processed. Your balance has been deducted. The status is not final. |
| SUCCESS | 00 | The product was delivered. sn contains the serial number if the product has one. Final status. |
| FAILED | 02 | The product could not be delivered. A refund is in progress — this status is temporary. |
| REFUNDED | 03 | The balance has been returned to your account. Final status. |
You will receive a callback on SUCCESS and REFUNDED. If an order stays in PROCESSING longer than usual, it is awaiting manual handling — do not send a replacement order, because the original order may still end in success.
Key rules
The price is checked at order time
The price field is optional. If you send it, it is compared against the Distributor-tier price in effect at that moment. If they differ, the order is rejected — it is never silently processed at a different price. This protects both parties when your catalog lags behind our price changes.
If price is omitted, the order is charged at the current price. The amount charged is returned in data.price and data.total. We recommend still sending price so that price changes never go unnoticed.
One reference number, one order
reference_number must be unique within your account. This is what makes resending safe when the network misbehaves.
Failures are always refunded
No failed order holds on to your balance. Refunds run automatically and are recorded in your balance history.
Rate limit
Maximum 100 requests per second per account. Exceeding it returns rc 99 temporarily.