Getting started
- Create an account and fund your wallet. Orders are paid instantly from your USD wallet. There are no cards and no invoices.
- Create an API key in Dashboard → API keys. Restrict it to your server's IP addresses.
- Browse the catalog with GET /products, then place an order with POST /orders.
- Add a webhook endpoint to hear about completed orders instead of polling.
curl https://forastack.com/api/v1/balance \
-H "Authorization: Bearer fsk_live_xxxxxxxxxxxx_…" Prefer clicking? Every endpoint below has a live console.
Authentication
Send your key in the Authorization header as a Bearer token. X-Api-Key: fsk_live_… also works. Keys look like fsk_live_{12 characters}_{40 characters} and are shown once when created. We store only a keyed hash.
| Situation | Response |
|---|---|
| Missing, wrong or revoked key | 401 UNAUTHORIZED |
| Request from an IP outside the key's allowlist | 403 FORBIDDEN_IP |
| Suspended or unverified account | 403 ACCOUNT_RESTRICTED |
| Too many failed attempts from one IP | 429 RATE_LIMITED |
Responses & errors
Every response is JSON with the same envelope. Money is a decimal string in USD with two places ("12.50"). Times are ISO 8601 UTC. Every response carries an X-Request-Id header: include it when you contact support.
{
"success": true,
"data": { … },
"meta": { "request_id": "4f1c9a7e2b6d3a10" }
} {
"success": false,
"error": {
"code": "INSUFFICIENT_BALANCE",
"message": "Your wallet balance is too low for this order."
},
"meta": { "request_id": "9a0d…" }
} Branch on error.code, never on the message text. Validation errors may include error.details.fields.
| Code | HTTP | Meaning |
|---|---|---|
| UNAUTHORIZED | 401 | Missing or invalid API key. |
| ACCOUNT_RESTRICTED | 403 | This account can't use the API right now. Contact support. |
| FORBIDDEN_IP | 403 | Requests from this IP address are not allowed for this API key. |
| API_DISABLED | 503 | The API is temporarily unavailable. Please try again later. |
| RATE_LIMITED | 429 | Too many requests. Slow down and retry after the time in the Retry-After header. |
| VALIDATION_ERROR | 422 | Some of the request fields are invalid. |
| IDEMPOTENCY_KEY_REQUIRED | 400 | This request needs an Idempotency-Key header (8 to 100 characters: letters, digits, - _ : .). |
| IDEMPOTENCY_CONFLICT | 422 | This Idempotency-Key was already used with a different request. |
| IDEMPOTENCY_IN_PROGRESS | 409 | A request with this Idempotency-Key is still being processed. Retry shortly. |
| INSUFFICIENT_BALANCE | 402 | Your wallet balance is too low for this order. |
| PRODUCT_UNAVAILABLE | 409 | This product is temporarily unavailable. |
| OUT_OF_STOCK | 409 | This product is out of stock. |
| ROUTE_UNAVAILABLE | 409 | That delivery route is unavailable right now. Use "auto" or another route. |
| PRICE_CHANGED | 409 | The price is above your max_price. Fetch the product again and retry. |
| PLAYER_NOT_FOUND | 422 | The player ID could not be found. |
| REGION_MISMATCH | 422 | This account is in a different region than the product. |
| RISK_LIMIT | 403 | This order is above your current account limit. Contact support to raise it. |
| NOT_FOUND | 404 | Not found. |
| ORDER_NOT_FOUND | 404 | No order with that id belongs to this account. |
| METHOD_NOT_ALLOWED | 405 | This method is not allowed on this endpoint. |
| WEBHOOK_NOT_CONFIGURED | 409 | Set up a webhook endpoint in your dashboard first. |
| TEMPORARY_FAILURE | 503 | Temporary problem on our side. Retry the same request in a moment. |
| INTERNAL_ERROR | 500 | Something went wrong on our side. Retry the same request; it is safe with the same Idempotency-Key. |
Rate limits
Each key may make 120 requests per minute on the Basic plan. Paid plans raise this, and your current limit is in GET /balance. Every response includes:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1792412460 (unix time when the window resets) Over the limit you get 429 RATE_LIMITED with a Retry-After header in seconds. Back off and retry. Repeating an order with the same Idempotency-Key is always safe.
Idempotency
POST /orders requires an Idempotency-Key header: a unique string per order of 8 to 100 characters (letters, digits, - _ : .). Use your own order id or a UUID. It is what makes retries safe: one key can never buy twice, even with 100 requests in flight at once.
| You send | We answer |
|---|---|
| A new key | 201 with the new order |
| The same key and the same body again | The same order, with its current status, the original status code and the header Idempotent-Replayed: true |
| The same key with a different body | 422 IDEMPOTENCY_CONFLICT |
| The same key while the first request is still running | 409 IDEMPOTENCY_IN_PROGRESS. Retry after a second. |
| A key whose request failed (for example INSUFFICIENT_BALANCE) | Nothing was created, so you can retry with the same key after fixing the cause |
Replays are remembered for 48 hours. After that, a key still maps to its order forever, so never reuse a key for a different purchase.
Prices & plans
Every price the API returns already includes your plan discount. That is exactly what POST /orders charges. Products show price (yours) next to list_price. To protect yourself from price moves between quote and order, send max_price: we reject with 409 PRICE_CHANGED instead of charging more.
Endpoints
/balance/categories/routes/products/products/{id}/products/{id}/requirements/players/validate/orders/orders/{id}/orders/webhooks/test/balanceYour wallet balance (truncated, never rounded up), your plan and the per-key rate limit.
{
"balance": "184.20",
"currency": "USD",
"plan": { "slug": "pro", "name": "Pro", "discount_pct": "6", "max_discount_pct": "6",
"cycle": "monthly", "ends_at": "2026-11-19T10:00:00Z", "auto_renew": true },
"rate_limit_per_min": 600
}
/categoriesActive categories, two levels deep: [{ slug, name, kind, children: [{ slug, name, kind }] }]. Use a slug to filter products.
/routesDelivery routes you can request: [{ route, name, description, eta, is_auto }]. auto (recommended) picks the cheapest, healthiest way to deliver and switches automatically if one fails.
/productsQuery: q (search), category (slug), type (gift_card · voucher · direct_topup), region (e.g. GLOBAL, US), page, per_page (max 100).
curl "https://forastack.com/api/v1/products?type=direct_topup&q=free%20fire&per_page=20" \
-H "Authorization: Bearer $FORASTACK_KEY" {
"success": true,
"data": [{
"id": "0192a7c4-…", "slug": "free-fire-global", "name": "Free Fire Diamonds",
"type": "direct_topup", "brand": "Garena", "region": { "code": "GLOBAL", "label": "Global" },
"from_price": "0.94", "from_list_price": "1.00", "image_url": "https://…"
}],
"meta": { "page": 1, "per_page": 20, "total": 1, "pages": 1, "request_id": "…" }
}
/products/{id}{id} is the product id or slug. Returns the product, its fields (see requirements) and variants with your price per delivery route. Order a variant by its id.
"variants": [{
"id": "0192a7c5-…", "sku": "FS-SKU-7K2M9QX4TB", "name": "100 Diamonds",
"available": true, "price": "0.94", "list_price": "1.00", "discount_pct": "6",
"routes": [
{ "route": "auto", "name": "Auto", "price": "0.94", "eta": "Instant", "reliability": "Excellent", "recommended": true },
{ "route": "swift", "name": "Swift", "price": "0.97", "eta": "Under a minute", "reliability": "Reliable", "recommended": false }
]
}]
/products/{id}/requirementsThe fields an order must include: key, label, type (text · number · email · select), required, options, pattern, min_length, max_length, placeholder, help, plus whether the player ID can be validated.
/players/validateCheck a player before you charge your customer. Body: { "variant": "…", "fields": { "player_id": "5550001" } }.
{ "valid": true, "status": "valid", "code": null, "message": "Player found.", "player_name": "NightOwl", "region": "SG" } valid is true, false (with code PLAYER_NOT_FOUND or REGION_MISMATCH) or null when the game can't be checked automatically (status: unsupported or unavailable). Orders still run their own check before charging.
/ordersPlaces one order (one unit) paid from your wallet. Body:
| variant | Variant id. Required. |
| fields | Object of required fields, e.g. { "player_id": "5550001" }. |
| route | "auto" (default) or a route from GET /routes. Only auto switches routes on failure. |
| max_price | Optional. Reject instead of charging more than this. |
| wait | Optional true: wait up to 25 s for delivery and return the final status with codes in one call. By default we answer immediately with processing. |
curl -X POST https://forastack.com/api/v1/orders \
-H "Authorization: Bearer $FORASTACK_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: shop-order-48213" \
-d '{"variant":"0192a7c5-…","fields":{"player_id":"5550001"},"max_price":"1.00"}' HTTP/1.1 201 Created
{
"success": true,
"data": {
"id": "0192a8d1-…", "order_no": "FS-7K2M9QX4TB", "status": "processing",
"product": { "name": "Free Fire Diamonds", "variant": "100 Diamonds", "sku": "FS-SKU-…", "region": { "code": "GLOBAL", "label": "Global" } },
"route": "auto", "quantity": 1, "list_price": "1.00", "discount": "0.06", "discount_pct": "6", "plan": "Pro",
"total": "0.94", "currency": "USD", "recipient": "555•••01", "player_name": "NightOwl",
"failure": null, "deliverables_count": 0, "created_at": "2026-10-19T12:00:00Z"
}
} Statuses: processing → completed, or refunded when delivery fails (the full amount returns to your wallet automatically; failure.code says why). on_hold means a short manual review. Charging and the order are one atomic step: if you get an error, nothing was charged.
/orders/{id}{id} is the order id or order_no. Completed orders include deliverables: [{ kind: "code"|"pin"|"serial"|"link"|"expiry", label, value }] and redeem_instructions. Codes appear only here, never in webhooks or emails.
/ordersNewest first. Query: status, from, to (ISO dates), limit (max 100), cursor. Pass meta.next_cursor as cursor to page. Lists never include codes.
/webhooks/testSends a signed webhook.test event to your endpoint and returns { event_id, delivery_id, delivered, http_status, error, duration_ms }.
Webhooks
Set an HTTPS endpoint and choose events in Dashboard → Webhooks. We POST JSON and expect any 2xx within 10 seconds.
| Event | When |
|---|---|
| order.completed | Order completed |
| order.failed | Order failed (automatically refunded) |
| order.refunded | Order refunded by support |
| wallet.funded | Wallet funded |
| plan.renewed | Plan renewed |
| plan.expiring | Plan ending soon |
| plan.expired | Plan ended |
| webhook.test | You pressed "Send test webhook" |
POST /your/endpoint
Content-Type: application/json
X-ForaStack-Event: order.completed
X-ForaStack-Delivery: 0192a8d4-… (unique per attempt)
X-ForaStack-Timestamp: 1792412400
X-ForaStack-Signature: v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"id": "0192a8d3-…", (the same on every retry: use it to dedupe)
"type": "order.completed",
"api_version": "2026-10-19",
"created_at": "2026-10-19T12:00:04Z",
"data": { "order": { …same shape as GET /orders/{id}, without deliverables… } }
} Verify the signature
Compute HMAC-SHA256(secret, timestamp + "." + raw_body) in hex, prefix it with v1= and compare it in constant time with X-ForaStack-Signature. Use the raw request bytes: re-encoding the JSON changes them. Reject timestamps older than 5 minutes.
<?php
// Verify a ForaStack webhook (PHP 7.4+)
function forastack_verify(string $rawBody, string $timestamp, string $signature, string $secret, int $tolerance = 300): bool
{
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $tolerance) {
return false; // missing or too old: possible replay
}
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}
// In your webhook endpoint:
// $raw = file_get_contents('php://input'); // the exact bytes, before json_decode
// $ok = forastack_verify($raw, $_SERVER['HTTP_X_FORASTACK_TIMESTAMP'] ?? '',
// $_SERVER['HTTP_X_FORASTACK_SIGNATURE'] ?? '', getenv('FORASTACK_WEBHOOK_SECRET'));
// if (!$ok) { http_response_code(400); exit; }
// $event = json_decode($raw, true); // skip it if you already processed $event['id']
// http_response_code(200);
// Verify a ForaStack webhook (Node.js 16+)
const crypto = require('crypto');
function forastackVerify(rawBody, timestamp, signature, secret, tolerance = 300) {
if (!/^\d+$/.test(timestamp || '') || Math.abs(Date.now() / 1000 - Number(timestamp)) > tolerance) {
return false; // missing or too old: possible replay
}
const expected = 'v1=' + crypto.createHmac('sha256', secret).update(timestamp + '.' + rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: keep the raw body for verification
// app.post('/webhooks/forastack', express.raw({ type: 'application/json' }), (req, res) => {
// const ok = forastackVerify(req.body.toString('utf8'), req.get('X-ForaStack-Timestamp'),
// req.get('X-ForaStack-Signature'), process.env.FORASTACK_WEBHOOK_SECRET);
// if (!ok) return res.sendStatus(400);
// const event = JSON.parse(req.body); // skip it if you already processed event.id
// res.sendStatus(200);
// });
module.exports = { forastackVerify };
# Verify a ForaStack webhook (Python 3.7+)
import hashlib
import hmac
import time
def forastack_verify(raw_body: bytes, timestamp: str, signature: str, secret: str, tolerance: int = 300) -> bool:
if not (timestamp or "").isdigit() or abs(time.time() - int(timestamp)) > tolerance:
return False # missing or too old: possible replay
message = timestamp.encode() + b"." + raw_body
expected = "v1=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or "")
# Flask:
# @app.post("/webhooks/forastack")
# def forastack_webhook():
# ok = forastack_verify(request.get_data(), request.headers.get("X-ForaStack-Timestamp", ""),
# request.headers.get("X-ForaStack-Signature", ""), os.environ["FORASTACK_WEBHOOK_SECRET"])
# if not ok:
# abort(400)
# event = request.get_json() # skip it if you already processed event["id"]
# return "", 200
Retries
If your endpoint doesn't answer 2xx, we retry after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h (8 attempts in total, with a little jitter). After 20 failures in a row we turn the endpoint off and email you. Every attempt and your response is shown in the delivery log, where you can also resend. Events may arrive more than once or out of order: dedupe by id, and fetch GET /orders/{id} when you need the latest state.
Security checklist
- Keys live only on your server, restricted with an IP allowlist.
- Every order has a unique Idempotency-Key, and you retry with the same key.
- Send max_price so a price change can never charge you more.
- Verify every webhook signature and timestamp, and dedupe by event id.
- Show codes to your customer only after status is completed.
Questions? support@forastack.com. Include the X-Request-Id.