forastackAPI v1
Reseller API

Sell ForaStack products from your own platform.

A JSON REST API with plan-discounted prices, idempotent orders that can't double-charge, instant delivery and HMAC-signed webhooks.

Base URLhttps://forastack.com/api/v1

Getting started

  1. Create an account and fund your wallet. Orders are paid instantly from your USD wallet. There are no cards and no invoices.
  2. Create an API key in Dashboard → API keys. Restrict it to your server's IP addresses.
  3. Browse the catalog with GET /products, then place an order with POST /orders.
  4. 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.

Keep keys on your server. Never put a key in a browser, mobile app or public repository. Anyone holding it can spend your wallet. Add an IP allowlist and revoke keys you no longer use.
SituationResponse
Missing, wrong or revoked key401 UNAUTHORIZED
Request from an IP outside the key's allowlist403 FORBIDDEN_IP
Suspended or unverified account403 ACCOUNT_RESTRICTED
Too many failed attempts from one IP429 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.

CodeHTTPMeaning
UNAUTHORIZED401Missing or invalid API key.
ACCOUNT_RESTRICTED403This account can't use the API right now. Contact support.
FORBIDDEN_IP403Requests from this IP address are not allowed for this API key.
API_DISABLED503The API is temporarily unavailable. Please try again later.
RATE_LIMITED429Too many requests. Slow down and retry after the time in the Retry-After header.
VALIDATION_ERROR422Some of the request fields are invalid.
IDEMPOTENCY_KEY_REQUIRED400This request needs an Idempotency-Key header (8 to 100 characters: letters, digits, - _ : .).
IDEMPOTENCY_CONFLICT422This Idempotency-Key was already used with a different request.
IDEMPOTENCY_IN_PROGRESS409A request with this Idempotency-Key is still being processed. Retry shortly.
INSUFFICIENT_BALANCE402Your wallet balance is too low for this order.
PRODUCT_UNAVAILABLE409This product is temporarily unavailable.
OUT_OF_STOCK409This product is out of stock.
ROUTE_UNAVAILABLE409That delivery route is unavailable right now. Use "auto" or another route.
PRICE_CHANGED409The price is above your max_price. Fetch the product again and retry.
PLAYER_NOT_FOUND422The player ID could not be found.
REGION_MISMATCH422This account is in a different region than the product.
RISK_LIMIT403This order is above your current account limit. Contact support to raise it.
NOT_FOUND404Not found.
ORDER_NOT_FOUND404No order with that id belongs to this account.
METHOD_NOT_ALLOWED405This method is not allowed on this endpoint.
WEBHOOK_NOT_CONFIGURED409Set up a webhook endpoint in your dashboard first.
TEMPORARY_FAILURE503Temporary problem on our side. Retry the same request in a moment.
INTERNAL_ERROR500Something 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 sendWe answer
A new key201 with the new order
The same key and the same body againThe same order, with its current status, the original status code and the header Idempotent-Replayed: true
The same key with a different body422 IDEMPOTENCY_CONFLICT
The same key while the first request is still running409 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

Live console

Every "Try it" sends a real request to the production API with your key. There is no sandbox. Reads are free; orders really charge your wallet.

Sign in to create a key right here, or paste one from Dashboard → API keys.

GET/balance

Your 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
}
Try it liveNo API key yet
GET/categories

Active categories, two levels deep: [{ slug, name, kind, children: [{ slug, name, kind }] }]. Use a slug to filter products.

Try it liveNo API key yet
GET/routes

Delivery 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.

Try it liveNo API key yet
GET/products

Query: 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": "…" }
}
Try it liveNo API key yet
GET/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 }
  ]
}]
Try it liveNo API key yet
GET/products/{id}/requirements

The 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.

Try it liveNo API key yet
POST/players/validate

Check 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.

Try it liveNo API key yet

Live check against the game. No charge, but it counts towards your player-check limit.

POST/orders

Places one order (one unit) paid from your wallet. Body:

variantVariant id. Required.
fieldsObject of required fields, e.g. { "player_id": "5550001" }.
route"auto" (default) or a route from GET /routes. Only auto switches routes on failure.
max_priceOptional. Reject instead of charging more than this.
waitOptional 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.

Try it liveNo API key yet

Real order. There is no sandbox: this places a live order and debits your wallet. Delivered codes are real. Keep max_price to cap the charge.

GET/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.

Try it liveNo API key yet
GET/orders

Newest first. Query: status, from, to (ISO dates), limit (max 100), cursor. Pass meta.next_cursor as cursor to page. Lists never include codes.

Try it liveNo API key yet
POST/webhooks/test

Sends a signed webhook.test event to your endpoint and returns { event_id, delivery_id, delivered, http_status, error, duration_ms }.

Try it liveNo API key yet

Sends a real, signed test event to your webhook endpoint. No charge.

Webhooks

Set an HTTPS endpoint and choose events in Dashboard → Webhooks. We POST JSON and expect any 2xx within 10 seconds.

EventWhen
order.completedOrder completed
order.failedOrder failed (automatically refunded)
order.refundedOrder refunded by support
wallet.fundedWallet funded
plan.renewedPlan renewed
plan.expiringPlan ending soon
plan.expiredPlan ended
webhook.testYou 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.