Finxa API Docs

Getting Started

Overview

The Finxa Admin API is a RESTful API that lets you programmatically manage your store's products, orders, customers, collections, discounts, shipping, and more. Build custom integrations, automate workflows, or connect your store to third-party services.

All API endpoints are available at:

Base URL
text
https://api.finxa.store/api

Every response follows a consistent JSON envelope:

Success response
json
{
  "success": true,
  "data": { ... },
  "message": "Product created"
}
Error response
json
{
  "success": false,
  "error": "Product not found"
}

Authentication

To use the API, you need a Custom App. Create one from your Finxa dashboard under Settings → Apps.

Step 1: Create an App

Navigate to Dashboard → Apps → Create app. Give it a name and select the API scopes you need.

Step 2: Install & Get Credentials

Click "Install app" to activate it. You'll receive two credentials:

ParameterTypeDescription
API KeystringPublic identifier for your app. Starts with fxk_. Safe to store in config.
Secret KeystringPrivate access token. Starts with fxat_. Treat it like a password — never commit to version control.
⚠️The Secret Key is only shown once when you install the app. Store it securely. If you lose it, you'll need to rotate the key from the dashboard.

Step 3: Authenticate Requests

Include your secret key in the X-Finxa-Access-Token header:

Authenticated request
bash
curl https://api.finxa.store/api/products \
  -H "X-Finxa-Access-Token: fxat_your_secret_key_here"
💡The API key (fxk_) is your app's public identifier. The secret key (fxat_) is what you send in the header. Don't confuse them.

Making Requests

All request and response bodies use JSON. Set the Content-Type header for requests that include a body:

POST / PATCH / PUT requests
bash
curl -X POST https://api.finxa.store/api/products \
  -H "X-Finxa-Access-Token: fxat_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Premium T-Shirt",
    "status": "ACTIVE",
    "variants": [
      { "title": "Default", "price": 29.99, "inventory": 100 }
    ]
  }'

Response Format

Every response is a JSON object with a success boolean. Successful responses include a data field. Error responses include an error string.

ParameterTypeDescription
successRequiredbooleanWhether the request succeeded.
dataobject | arrayThe response payload. Present on success.
messagestringA human-readable success message. Not always present.
errorstringError description. Present on failure.

Paginated Responses

List endpoints support pagination via query parameters:

ParameterTypeDescription
pagenumberPage number (1-indexed). Default: 1
limitnumberItems per page. Default: 20, max: 100
Paginated response
json
{
  "success": true,
  "data": {
    "products": [ ... ],
    "pagination": {
      "total": 156,
      "page": 1,
      "limit": 20,
      "totalPages": 8
    }
  }
}

Rate Limits

Each app token gets its own budget, so one busy integration never slows the dashboard or another app:

ParameterTypeDescription
App tokenslimit300 requests per minute per app, with X-RateLimit-Limit / X-RateLimit-Remaining on every response
Storefront tokenslimit300 requests per minute per client IP on /api/storefront

When rate limited you receive 429 Too Many Requests with a Retry-After header (seconds). Back off and retry after that.

Errors

The API uses standard HTTP status codes:

ParameterTypeDescription
200OKRequest succeeded.
201CreatedResource created successfully.
400Bad RequestInvalid request body or parameters.
401UnauthorizedMissing or invalid access token.
403ForbiddenInsufficient scopes for this action.
404Not FoundResource doesn't exist or doesn't belong to your store.
409ConflictResource conflict (duplicate email, can't delete with related records).
429Rate LimitedToo many requests. Retry after a short delay.
500Internal ErrorSomething went wrong on our side. Contact support.
Scope error example
json
{
  "success": false,
  "error": "Insufficient scopes. Required: write_products"
}

Webhooks

Webhooks push events to your endpoint the moment something changes in the store — a new order, a product edit, stock going down at the POS — so you never have to poll. Each delivery is signed, retried when your endpoint is down, and logged so both you and the merchant can see what happened.

💡Merchants add subscriptions from Dashboard → Apps → your app → Webhooks (topic + endpoint). The signing secret is shown once, and the same screen has a Send test button and the delivery log with Redeliver.

Topics

ParameterTypeDescription
orders/createread_ordersAn order was placed (online checkout, POS sale, or a completed draft).
orders/paidread_ordersPayment for an order was confirmed.
orders/updatedread_ordersAnything on an order changed: status, items, addresses, notes, tracking, refunds.
orders/fulfilledread_ordersAn order was marked fulfilled (tracking details included when set).
orders/cancelledread_ordersAn order was cancelled by the merchant or because payment failed.
refunds/createread_ordersA refund was recorded against an order.
products/createread_productsA product was created (including duplicates and CSV imports).
products/updateread_productsA product, one of its variants, or its images changed.
products/deleteread_productsA product was deleted.
collections/createread_collectionsA collection was created.
collections/updateread_collectionsA collection changed.
collections/deleteread_collectionsA collection was deleted.
inventory_levels/updateread_productsA tracked variant's available quantity changed (orders, returns, manual adjustments, POS sales).
customers/createread_customersA customer was created.
customers/updateread_customersA customer's profile changed.
customers/deleteread_customersA customer was deleted.
app/uninstalled—The merchant revoked this app. Sent to every webhook the app had, then the app's tokens stop working.

The third column is the scope the app must hold to subscribe to that topic.

Envelope & headers

Every delivery is a POST with a JSON envelope. `data` carries the resource in the same shape the admin API returns it (orders/* carry the order as GET /api/orders/:id does; products/* the product; inventory_levels/update an inventory_level object; refunds/create the refund and its order).

Envelope (orders/create)
json
{
  "id": "evt_9f1c2b7a4d3e8f10aa11bb22",
  "topic": "orders/create",
  "api_version": "2026-09",
  "created_at": "2026-09-18T06:20:11.000Z",
  "store_id": "cmu6e4ndz0001…",
  "data": {
    "order": { "id": "cmu6e4nxq005k…", "orderNumber": 1042, "status": "PAID", "total": "24.690", "items": [ … ] }
  }
}

Headers

ParameterTypeDescription
X-Finxa-TopicheaderThe topic, e.g. orders/create
X-Finxa-Hmac-SHA256headerBase64 HMAC-SHA256 of the raw request body, keyed with the webhook's secret
X-Finxa-Event-IdheaderEvent id — identical across every webhook that receives the same event, and across retries
X-Finxa-Delivery-IdheaderThis delivery attempt's row id (shown in the dashboard log)
X-Finxa-Webhook-IdheaderThe subscription id
X-Finxa-Api-VersionheaderEnvelope/payload version
X-Finxa-TestheaderPresent ("true") only for test deliveries sent from the dashboard

Verifying signatures

Compute the HMAC over the raw bytes you received — before any JSON parsing or re-serialisation — and compare it in constant time. Respond 2xx within 10 seconds; do the real work asynchronously.

Node.js (Express)
javascript
import { createHmac, timingSafeEqual } from "node:crypto";

app.post("/webhooks/finxa", express.raw({ type: "application/json" }), (req, res) => {
  const expected = createHmac("sha256", process.env.FINXA_WEBHOOK_SECRET)
    .update(req.body)              // the raw bytes, not a re-serialised object
    .digest("base64");
  const given = req.get("X-Finxa-Hmac-SHA256") ?? "";
  if (given.length !== expected.length || !timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body);
  // Store event.id and skip duplicates — retries redeliver the same id.
  res.status(200).end();           // answer fast; do the work in a queue
});
PHP
php
$raw = file_get_contents('php://input');
$expected = base64_encode(hash_hmac('sha256', $raw, $_ENV['FINXA_WEBHOOK_SECRET'], true));
if (!hash_equals($expected, $_SERVER['HTTP_X_FINXA_HMAC_SHA256'] ?? '')) {
    http_response_code(401);
    exit;
}
$event = json_decode($raw, true);

Retries & delivery log

A delivery counts as successful on any 2xx. Anything else (a 4xx/5xx, a timeout after 10 s, a connection error) is retried on this schedule, about 8.6 hours in total, after which the delivery is marked failed and can be redelivered from the dashboard or the API:

ParameterTypeDescription
1attemptimmediately
2attemptafter 1 minute
3attempt5 minutes after that
4attempt30 minutes after that
5attempt2 hours after that
6attempt6 hours after that (last)
⚠️Retries resend the same event id. Store the ids you have processed and treat a repeat as a no-op — a slow endpoint can receive an event twice.

Registering from your app

An installed app registers its own subscriptions with its access token — the merchant never opens the dashboard for it. This is how a public app sets itself up on each store it is installed on. Reads need read_webhooks, writes need write_webhooks, and each topic also needs its own scope.

GET/api/app

The calling app and its store — works with any valid app token.

GET/api/app/topics

All topics with descriptions and required scopes

GET/api/app/webhooks

The app's own subscriptions.

Required scopes:read_webhooks
POST/api/app/webhooks

Subscribe to a topic. Returns the signing secret once; 409 topic_already_subscribed when the app already has that topic.

Required scopes:write_webhooks
PUT/api/app/webhooks/{webhookId}

Change the address or topic, or pause it with isActive: false.

Required scopes:write_webhooks
DELETE/api/app/webhooks/{webhookId}

Unsubscribe.

Required scopes:write_webhooks
POST/api/app/webhooks/{webhookId}/rotate-secret

New signing secret, shown once. Deliveries are signed with it immediately.

Required scopes:write_webhooks
POST/api/app/webhooks/{webhookId}/test

Send a signed test event so you can verify your receiver.

Required scopes:write_webhooks
GET/api/app/deliveries

Delivery log across the app's subscriptions, newest first (limit, cursor).

Required scopes:read_webhooks
POST/api/app/deliveries/{deliveryId}/redeliver

Queue a failed delivery again.

Required scopes:write_webhooks
Registering a webhook on install
bash
# On install, register the events your app needs — no dashboard visit required.
curl -X POST https://api.finxa.store/api/app/webhooks \
  -H "X-Finxa-Access-Token: fxat_…" \
  -H "Content-Type: application/json" \
  -d '{ "topic": "orders/paid", "address": "https://yourapp.com/hooks/finxa" }'

# 201 — the signing secret is in the response and is never shown again.
{ "success": true,
  "data": { "id": "…", "topic": "orders/paid", "address": "https://yourapp.com/hooks/finxa",
            "isActive": true, "secret": "9f86d0818…" },
  "warning": "Save the secret now — it signs every delivery (X-Finxa-Hmac-SHA256) and is only shown once." }

# Already subscribed to that topic? 409 with the existing id, so you can update it instead.
{ "success": false, "code": "topic_already_subscribed", "webhookId": "…" }
⚠️Subscribing needs two things: write_webhooks, and the scope the topic itself reads. orders/paid needs read_orders, products/update needs read_products. Ask for both at install or the subscription is refused with 403.

Managing subscriptions

Subscriptions belong to an app and are managed with the merchant's dashboard session (the same calls the Apps page makes). One subscription per topic per app.

GET/api/apps/topics

All topics with descriptions and required scopes

POST/api/apps/{storeId}/{appId}/webhooks

Subscribe { topic, address } — returns the signing secret once

PUT/api/apps/{storeId}/{appId}/webhooks/{webhookId}

Change address/topic or toggle isActive

POST/api/apps/{storeId}/{appId}/webhooks/{webhookId}/test

Send a synthetic event now and return the recorded attempt

GET/api/apps/{storeId}/{appId}/webhooks/{webhookId}/deliveries

Delivery log (status, attempts, last status code, error)

POST/api/apps/{storeId}/{appId}/deliveries/{deliveryId}/redeliver

Queue a fresh round of attempts for one delivery

Rate limits, idempotency & versions

Three things every app-token request gets for free: a per-app rate limit, idempotency keys for safe retries, and a dated API version you can pin.

Rate limiting

300 requests per minute per app. Every response tells you where you stand; a 429 tells you how long to wait.

ParameterTypeDescription
X-RateLimit-LimitheaderRequests allowed per minute
X-RateLimit-RemainingheaderRequests left in the current minute
Retry-AfterheaderOn 429 — seconds to wait before retrying

Idempotency keys

Send Idempotency-Key (any string up to 255 characters, unique per operation) on POST/PUT/PATCH/DELETE. If the same key arrives again within 24 hours for the same app, method and path, the stored response is returned with Idempotent-Replayed: true and the handler does not run again. A repeat that arrives while the first request is still running gets 409.

Safe retry of a draft order
bash
curl -X POST https://api.finxa.store/api/orders/draft \
  -H "X-Finxa-Access-Token: fxat_…" \
  -H "Idempotency-Key: order-sync-8842" \
  -H "Content-Type: application/json" \
  -d '{ "items": [{ "variantId": "cmu…", "quantity": 1 }], "customerEmail": "[email protected]" }'

# Same key again within 24 h → the stored response, plus:
# Idempotent-Replayed: true
ℹ️Keys are scoped to the app, method and path, so reusing a key on a different endpoint is a new operation.

API versioning

API versions are dates. Send X-Finxa-API-Version to pin one; omit it to get the current version. Every response echoes the version it was served with. When a breaking change ships it comes as a new dated version and the previous one keeps working for at least twelve months. Unknown versions are rejected with 400.

Pinning a version
bash
curl https://api.finxa.store/api/products \
  -H "X-Finxa-Access-Token: fxat_…" \
  -H "X-Finxa-API-Version: 2026-09"

# Every response echoes the version it was served with:
# X-Finxa-API-Version: 2026-09

Request log

Every app-token request is logged (method, path, status, duration, IP) and kept for 30 days. Merchants see the count on the app page; the log is available at GET /api/apps/{storeId}/{appId}/logs with a dashboard session.

OpenAPI

A machine-readable OpenAPI 3.1 description of every endpoint, built from the live route table, for client generators and API tools:

OpenAPI 3.1
bash
https://api.finxa.store/api/openapi.json

Storefront API (headless)

Build your own storefront — a custom Next.js site, a mobile app, a kiosk — on the same catalog, carts and checkout the hosted storefront uses. Read the catalog, build a cart, then hand the shopper to the hosted checkout, which already handles payments, markets, taxes and phone validation.

ℹ️Storefront tokens (fxst_…) are public by design: they can only reach /api/storefront, never the admin API, so they are safe to ship in a browser bundle or an app. Create one in Dashboard → Apps → your app → Storefront API tokens.

Storefront tokens

Send the token as X-Finxa-Storefront-Token (or Authorization: Bearer fxst_…). CORS is open on this prefix, so browsers can call it directly. Requests are limited to 300 per minute per client IP.

List products
bash
curl "https://api.finxa.store/api/storefront/products?limit=12&sort=created-desc" \
  -H "X-Finxa-Storefront-Token: fxst_…"

Catalog

GET/api/storefront/shop

Store name, currency, language, public URL and active markets

GET/api/storefront/products

Active products, newest first

GET/api/storefront/products/{handle}

One product by handle or id

GET/api/storefront/search?q=

Search by title, description, vendor or tag

GET/api/storefront/collections

Published collections

GET/api/storefront/collections/{handle}/products

Products in a collection (same filters as /products)

Query Parameters

ParameterTypeDescription
limitnumber1–100, default 24
cursorstringnext_cursor from the previous page
qstringFree-text search
collectionstringCollection handle
tagstringExact tag
sortstringcreated-desc (default), created-asc, title-asc, title-desc
Product shape
json
{
  "success": true,
  "data": [
    {
      "id": "cmu…", "title": "Racing Polo", "handle": "racing-polo",
      "available": true,
      "price_range": { "min": { "amount": "12.500", "currency": "BHD" }, "max": { "amount": "12.500", "currency": "BHD" } },
      "images": [{ "src": "https://…/polo.jpg", "alt": "Racing Polo", "width": 1600, "height": 1600 }],
      "options": [{ "name": "Size", "values": ["S", "M", "L"] }],
      "variants": [{ "id": "cmu…", "title": "S", "price": { "amount": "12.500", "currency": "BHD" }, "available": true, "options": { "Size": "S" } }]
    }
  ],
  "pagination": { "limit": 12, "has_more": true, "next_cursor": "cmu…" }
}

Carts

Carts live on the server for 7 days. Every cart response includes totals in the store currency and a checkout_url. Quantities are clamped to what can actually be bought; lines whose product stops being sold disappear.

POST/api/storefront/carts

Create a cart, optionally with lines

GET/api/storefront/carts/{id}

Cart with totals and checkout_url

POST/api/storefront/carts/{id}/items

Add a line (merges with an existing line for the same variant)

PATCH/api/storefront/carts/{id}/items/{variantId}

Set a line's quantity; 0 removes it

DELETE/api/storefront/carts/{id}/items/{variantId}

Remove a line

POST/api/storefront/carts/{id}/checkout

Returns checkout_url for the hosted checkout

Cart flow (browser)
javascript
// 1. create a cart with a first line
const cart = await fetch("https://api.finxa.store/api/storefront/carts", {
  method: "POST",
  headers: { "X-Finxa-Storefront-Token": TOKEN, "Content-Type": "application/json" },
  body: JSON.stringify({ items: [{ variantId: "cmu…", quantity: 2 }] }),
}).then((r) => r.json());

// 2. add / change lines as the shopper browses
await fetch(`https://api.finxa.store/api/storefront/carts/${cart.data.id}/items`, {
  method: "POST", headers, body: JSON.stringify({ variantId: "cmu…", quantity: 1 }),
});

// 3. send the shopper to the hosted checkout
window.location.href = cart.data.checkout_url;
// → https://<your-store-domain>/cart/load?cart=…&token=…  → /checkout

Discount codes

A shopper's code is validated against the same rules as the hosted checkout (minimum amount, product or collection scope, usage limits). The cart then carries the code: totals include it, and the hosted checkout applies it automatically after the handoff.

POST/api/storefront/carts/{id}/discount

Apply a code — 400 with code discount_invalid when it does not apply; the cart response includes discount and total.

DELETE/api/storefront/carts/{id}/discount

Remove the applied code.

Cart with a discount applied
json
// POST /api/storefront/carts/{id}/discount   { "code": "TEN" }
{
  "success": true,
  "data": {
    "id": "cart_…",
    "subtotal": { "amount": "45.000", "currency": "BHD" },
    "discount": { "code": "TEN", "title": "Ten off", "type": "PERCENTAGE", "value": 10,
                  "amount": { "amount": "4.500", "currency": "BHD" }, "free_shipping": false },
    "discount_error": null,
    "total": { "amount": "40.500", "currency": "BHD" },
    "checkout_url": "https://shop.example.com/cart/load?cart=…&token=…"
  }
}

Shipping rates

Rates for a destination country, computed from the store's zones and rules. Pass a cart_id to derive the subtotal and items, or send subtotal yourself. Optional market_id and locale (for translated rate names).

POST/api/storefront/shipping/rates

Rates for country_code — { country_code, cart_id? | subtotal?, market_id?, locale? } → data.rates[].

Shipping rates
bash
curl -X POST https://api.finxa.store/api/storefront/shipping/rates \
  -H "X-Finxa-Storefront-Token: fxst_…" -H "Content-Type: application/json" \
  -d '{ "country_code": "BH", "cart_id": "cart_…" }'
# or without a cart: { "country_code": "SA", "subtotal": 40.5, "market_id": "…", "locale": "ar" }

Menus, pages & blog

Everything a headless site needs besides the catalog: navigation menus (resolved to links), CMS pages, the auto-generated policy pages, and the blog. Only published content is returned.

GET/api/storefront/menus

Menus with their handle and item count.

GET/api/storefront/menus/{handle}

A menu resolved to a tree of links (collections, products, pages, custom URLs).

GET/api/storefront/pages

Published pages (without bodies; policy pages excluded).

GET/api/storefront/pages/{handle}

One page by handle, with its HTML body.

GET/api/storefront/policies

The store's policy pages: shipping, returns, contact, privacy, terms.

GET/api/storefront/blog

Published posts, newest first — limit, cursor, tag.

GET/api/storefront/blog/{handle}

One post by handle, with its HTML body.

Customer accounts

Shoppers sign in with a one-time code sent to their e-mail — the same login the hosted storefront uses — and your site gets a customer token (fxct_…, 30 days) to send as X-Finxa-Customer-Token. Accounts are created on first sign-in.

POST/api/storefront/customers/login/request

Send a 6-digit code to { email } — 10-minute validity, at most 3 per minute; 422 email_undeliverable when the address bounces.

POST/api/storefront/customers/login/verify

Exchange { email, code } for { customer_token, expires_at, customer } — 401 invalid_code after 5 wrong attempts.

GET/api/storefront/customers/me

The signed-in customer: profile and addresses.

PATCH/api/storefront/customers/me

Update first_name, last_name, phone, locale.

GET/api/storefront/customers/me/orders

The customer's orders (by customer id or e-mail), newest first, with items and tracking — limit, cursor.

POST/api/storefront/customers/logout

Invalidate the customer token.

Sign-in flow
javascript
// 1. ask for a code — the shopper gets a 6-digit code by e-mail (10 min, 3 per minute)
await sf("/customers/login/request", { method: "POST", body: JSON.stringify({ email }) });

// 2. exchange the code for a customer token (30 days)
const { data } = await (await sf("/customers/login/verify", {
  method: "POST", body: JSON.stringify({ email, code }) })).json();
localStorage.setItem("customer_token", data.customer_token);   // fxct_…

// 3. call account endpoints with the token
const me = await sf("/customers/me", { headers: { "X-Finxa-Customer-Token": data.customer_token } });
const orders = await sf("/customers/me/orders?limit=20", { headers: { "X-Finxa-Customer-Token": data.customer_token } });
⚠️Customer tokens are bearer secrets: keep them in memory or storage the shopper controls, never in your server logs. The storefront token identifies your site; the customer token identifies the shopper.

Checkout handoff

checkout_url points at the store's own domain: /cart/load?cart=…&token=…. That route copies the cart into the hosted storefront's cart cookie and redirects to /checkout, so the shopper pays through the merchant's configured gateways (TAP, Stripe, PayPal, Checkout.com, cash on delivery) with the merchant's shipping rates and taxes. Orders placed this way trigger orders/create like any other order.

💡Subscribe to orders/create and orders/paid to learn about the resulting order in your own system.

OAuth (public apps)

A public app is built once and installed by many merchants. The merchant approves it on a consent screen; your app receives a store-scoped access token that works on the admin API exactly like a custom-app token, until the merchant uninstalls.

Register an app

Register the app from Dashboard → Apps → Public apps (for developers), or with the API below. You get a client_id and a client_secret; declare the redirect URIs (exact match, https) and the widest set of scopes the app may ever request.

POST/api/public-apps

{ name, redirectUris[], scopes[] } → client_id + client_secret (once)

⚠️Keep the client_secret on your server. Never ship it in a browser or mobile app; use a storefront token for that.

Authorization flow

ParameterTypeDescription
1stepSend the merchant to /api/oauth/authorize with client_id, the scopes you need, a registered redirect_uri and a random state.
2stepFinxa validates the request and shows the merchant a consent screen in their dashboard (they sign in if needed). Requests with an unregistered redirect_uri are rejected outright; a scope outside your registration comes back to you as error=invalid_scope.
3stepOn approval the merchant is redirected to your redirect_uri with ?code=…&state=…. Check state, then exchange the code within 10 minutes — it is single-use.
4stepPOST /api/oauth/token with the code, your client credentials and the same redirect_uri. Store the returned access_token per store.
Step 1 — authorization URL
bash
https://api.finxa.store/api/oauth/authorize
  ?client_id=fxca_…
  &scope=read_orders,write_products
  &redirect_uri=https://yourapp.com/oauth/callback
  &state=8f3a…               # random, checked on return
  &code_challenge=…          # optional PKCE (S256)
  &code_challenge_method=S256
Step 4 — token exchange
bash
curl -X POST https://api.finxa.store/api/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "authorization_code",
    "code": "<code from the callback>",
    "client_id": "fxca_…",
    "client_secret": "fxcs_…",
    "redirect_uri": "https://yourapp.com/oauth/callback"
  }'

# {
#   "access_token": "fxat_…",
#   "token_type": "bearer",
#   "scope": "read_orders write_products",
#   "store": { "id": "cmu…", "handle": "sunglasses-bhr", "name": "Sunglasses BHR", "currency": "BHD" },
#   "app_id": "cmu…",
#   "api_version": "2026-09"
# }

Token errors follow OAuth: invalid_client (401) for a wrong id/secret, invalid_grant (400) for a used, expired or mismatched code, invalid_request (400) for a malformed body. PKCE (S256) is supported: send code_challenge on the authorize URL and code_verifier on the token exchange.

Access tokens

Access tokens are permanent offline tokens: they keep working until the merchant uninstalls the app (or your app uninstalls itself), and they carry exactly the scopes the merchant approved. Re-authorizing a store updates its scopes and issues a fresh token; the previous one stops working.

GET/api/oauth/me

Which store and app this token belongs to

DELETE/api/oauth/installs/current

Uninstall from the store: revokes the token and sends app/uninstalled

💡Subscribe to app/uninstalled so your system can stop syncing a store the moment the merchant removes the app.
ℹ️Right after the token exchange, register the webhooks that install needs with POST /api/app/webhooks using the new token — see Registering from your app. Ask for write_webhooks plus each topic's own scope in the authorize step.

Refresh tokens & expiry

By default access tokens are permanent. If your app sets an access-token lifetime (Apps page → Listing & tokens, or accessTokenTtlSeconds on PUT /api/public-apps/{id}), the token response carries expires_in and a refresh_token. Refresh tokens are single-use and rotate on every refresh; they last a year.

Expiring tokens and the refresh_token grant
bash
# token response when the app has an access-token lifetime set
{ "access_token": "fxat_…", "token_type": "bearer", "expires_in": 3600,
  "refresh_token": "fxrt_…", "scope": "read_orders write_products", "store": { … }, "app_id": "…" }

# before expires_in runs out (or on a 401): rotate both tokens
curl -X POST https://api.finxa.store/api/oauth/token \
  -H "Content-Type: application/json" \
  -d '{ "grant_type": "refresh_token", "refresh_token": "fxrt_…",
        "client_id": "fxca_…", "client_secret": "fxcs_…" }'
# → a new access_token + refresh_token; the old pair stops working immediately
ℹ️On a 401 with an expired token, refresh and retry once. If the refresh fails with invalid_grant the merchant uninstalled the app (or the refresh token was already used) — send them through authorization again.

App directory listing

Merchants see listed apps on their Apps page with an Install button. Set an install URL on your side that starts the authorization flow, then turn on the listing. Logo, category, support e-mail and description make the card.

PUT/api/public-apps/{id}

Update name, description, redirect URIs, scopes, listing fields and the access-token lifetime.

Listing fields
json
PUT /api/public-apps/{id}
{
  "listed": true,
  "installUrl": "https://yourapp.com/finxa/install",   // starts your OAuth flow
  "logoUrl": "https://yourapp.com/logo.png",
  "category": "shipping",
  "supportEmail": "[email protected]",
  "description": "Prints labels and syncs tracking numbers.",
  "accessTokenTtlSeconds": null                         // or e.g. 3600 for expiring tokens
}

Products

Products represent items in your store. Every product has at least one variant (even simple products). Variants hold the price, inventory, SKU, and options like size/color.

List Products

GET/api/products

Retrieve a paginated list of products.

Required scopes:read_products

Query Parameters

ParameterTypeDescription
pagenumberPage number. Default: 1
limitnumberItems per page (max 100). Default: 20
statusstringFilter by status: DRAFT, ACTIVE, or ARCHIVED
searchstringSearch by title, handle, vendor, type, variant title, SKU, or barcode
Example request
bash
curl "https://api.finxa.store/api/products?status=ACTIVE&limit=10" \
  -H "X-Finxa-Access-Token: fxat_your_secret_key"
Example response
json
{
  "success": true,
  "data": {
    "products": [
      {
        "id": "cmmm0ekmc0000ny1p7n1vdsbf",
        "title": "Premium T-Shirt",
        "handle": "premium-t-shirt",
        "status": "ACTIVE",
        "description": "A comfortable cotton t-shirt",
        "vendor": "Finxa Apparel",
        "productType": "Clothing",
        "tags": ["summer", "cotton"],
        "images": [],
        "options": [
          { "name": "Size", "position": 0, "values": ["S", "M", "L"] }
        ],
        "weight": null,
        "weightUnit": "kg",
        "isPhysical": true,
        "seoTitle": null,
        "seoDescription": null,
        "publishedAt": "2026-03-10T12:00:00.000Z",
        "createdAt": "2026-03-10T12:00:00.000Z",
        "updatedAt": "2026-03-10T12:30:00.000Z",
        "variants": [
          {
            "id": "clxyz123",
            "title": "Small",
            "sku": "TS-S",
            "barcode": null,
            "price": "29.990",
            "costPrice": "12.000",
            "compareAtPrice": null,
            "inventory": 50,
            "trackInventory": true,
            "taxable": true,
            "requiresShipping": true,
            "options": { "Size": "S" },
            "position": 0,
            "weight": null,
            "weightUnit": "kg"
          }
        ],
        "productImages": [],
        "_count": { "collections": 1 }
      }
    ],
    "pagination": {
      "total": 42,
      "page": 1,
      "limit": 10,
      "totalPages": 5
    }
  }
}

Get Product

GET/api/products/:productId

Retrieve a single product with all variants, images, and collection associations.

Required scopes:read_products
Example request
bash
curl "https://api.finxa.store/api/products/cmmm0ekmc0000ny1p7n1vdsbf" \
  -H "X-Finxa-Access-Token: fxat_your_secret_key"

Create Product

POST/api/products

Create a new product with at least one variant.

Required scopes:write_products

Request Body

ParameterTypeDescription
titleRequiredstringProduct title (1-255 chars)
descriptionstringPlain text description
descriptionHtmlstringRich HTML description
handlestringURL slug (auto-generated from title if omitted). Lowercase, numbers, and hyphens only.
statusstringDRAFT (default), ACTIVE, or ARCHIVED
vendorstringProduct vendor/brand name
productTypestringProduct type/category
tagsstring[]Array of tag strings
imagesstring[]Array of image URLs
optionsobject[]Product options, e.g. [{ "name": "Size", "values": ["S","M","L"] }]
weightnumberProduct weight
weightUnitstringWeight unit: kg (default), g, lb, oz
isPhysicalbooleanWhether product requires shipping. Default: true
seoTitlestringSEO title (max 70 chars)
seoDescriptionstringSEO description (max 320 chars)
variantsRequiredobject[]At least one variant (see Variant Object below)

Variant Object

ParameterTypeDescription
titleRequiredstringVariant title, e.g. "Small" or "Red / Large"
priceRequirednumberPrice (≥ 0)
skustringStock keeping unit
barcodestringBarcode (UPC, EAN, etc.)
costPricenumberCost of goods for profit tracking
compareAtPricenumberOriginal price for sale display
inventorynumberStock quantity. Default: 0
trackInventorybooleanTrack stock levels. Default: true
taxablebooleanCharge tax on this variant. Default: true
requiresShippingbooleanRequires shipping. Default: true
optionsobjectKey-value pairs, e.g. { "Size": "S", "Color": "Red" }
imagestringVariant-specific image URL
positionnumberSort position. Default: 0
weightnumberPer-variant weight (overrides product weight)
weightUnitstringWeight unit override
Example: Create a product with size variants
bash
curl -X POST "https://api.finxa.store/api/products" \
  -H "X-Finxa-Access-Token: fxat_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Classic Polo Shirt",
    "description": "A timeless polo in premium cotton.",
    "status": "ACTIVE",
    "vendor": "Finxa Apparel",
    "productType": "Clothing",
    "tags": ["polo", "cotton", "casual"],
    "options": [
      { "name": "Size", "position": 0, "values": ["S", "M", "L", "XL"] }
    ],
    "variants": [
      { "title": "Small",  "price": 35.00, "sku": "POLO-S",  "inventory": 25, "options": { "Size": "S" } },
      { "title": "Medium", "price": 35.00, "sku": "POLO-M",  "inventory": 40, "options": { "Size": "M" } },
      { "title": "Large",  "price": 35.00, "sku": "POLO-L",  "inventory": 30, "options": { "Size": "L" } },
      { "title": "XL",     "price": 37.50, "sku": "POLO-XL", "inventory": 15, "options": { "Size": "XL" } }
    ]
  }'

Update Product

PATCH/api/products/:productId

Update product fields. Only send the fields you want to change.

Required scopes:write_products
Example: Update title and tags
bash
curl -X PATCH "https://api.finxa.store/api/products/cmmm0ekmc0000ny1p7n1vdsbf" \
  -H "X-Finxa-Access-Token: fxat_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Premium Polo Shirt — Updated",
    "tags": ["polo", "premium", "new-arrival"]
  }'

Delete Product

DELETE/api/products/:productId

Permanently delete a product and all its variants. Products with existing orders cannot be deleted — archive them instead.

Required scopes:write_products
Example request
bash
curl -X DELETE "https://api.finxa.store/api/products/cmmm0ekmc0000ny1p7n1vdsbf" \
  -H "X-Finxa-Access-Token: fxat_your_secret_key"
⚠️If a product has associated orders, the API returns 409 Conflict. Use PATCH with {"status": "ARCHIVED"} instead.

Add Variant

POST/api/products/:productId/variants

Add a new variant to an existing product.

Required scopes:write_products
Example request
bash
curl -X POST "https://api.finxa.store/api/products/cmmm0ekmc0000ny1p7n1vdsbf/variants" \
  -H "X-Finxa-Access-Token: fxat_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "XXL",
    "price": 39.99,
    "sku": "POLO-XXL",
    "inventory": 10,
    "options": { "Size": "XXL" }
  }'

Update Variant

PATCH/api/products/:productId/variants/:variantId

Update an existing variant. Only send the fields you want to change.

Required scopes:write_products
Example: Update price and inventory
bash
curl -X PATCH "https://api.finxa.store/api/products/cmmm0ekmc0000ny1p7n1vdsbf/variants/clxyz123" \
  -H "X-Finxa-Access-Token: fxat_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{
    "price": 32.50,
    "inventory": 75,
    "costPrice": 14.00
  }'

Delete Variant

DELETE/api/products/:productId/variants/:variantId

Delete a variant. A product must always have at least one variant.

Required scopes:write_products
ℹ️You cannot delete the last variant on a product. The API returns 400 Bad Request if you try.

Bulk Sync Variants

PUT/api/products/:productId/variants/sync

Sync all variants in one call. Send the complete desired state — the API will create new variants, update existing ones, and delete any that aren't in your list.

Required scopes:write_products

This is an idempotent replace operation. Include an id on variants you want to update; omit it for new ones. Variants not in the list will be deleted.

Example: Replace all variants
bash
curl -X PUT "https://api.finxa.store/api/products/cmmm0ekmc0000ny1p7n1vdsbf/variants/sync" \
  -H "X-Finxa-Access-Token: fxat_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{
    "variants": [
      { "id": "existing_id_1", "title": "Small",  "price": 29.99, "inventory": 50 },
      { "id": "existing_id_2", "title": "Medium", "price": 29.99, "inventory": 40 },
      { "title": "Large",  "price": 29.99, "inventory": 30 },
      { "title": "XL",     "price": 34.99, "inventory": 20 }
    ]
  }'
💡Use bulk sync when managing multiple size/color variants. It's more efficient than individual create/update/delete calls and runs in a single database transaction.

Collections

Collections group products together. They can be manual (hand-picked products) or smart (auto-populated based on rules like tag, vendor, or price).

List Collections

GET/api/collections

Retrieve all collections for your store.

Required scopes:read_collections
Example request
bash
curl "https://api.finxa.store/api/collections" \
  -H "X-Finxa-Access-Token: fxat_your_secret_key"

Get Collection

GET/api/collections/:collectionId

Retrieve a single collection with its products.

Required scopes:read_collections

Create Collection

POST/api/collections

Create a new collection.

Required scopes:write_collections

Request Body

ParameterTypeDescription
titleRequiredstringCollection title
handlestringURL slug (auto-generated if omitted)
descriptionstringPlain text description
descriptionHtmlstringRich HTML description
imagestringCollection image URL
collectionTypestringMANUAL (default) or SMART
sortOrderstringMANUAL, BEST_SELLING, ALPHA_ASC, ALPHA_DESC, PRICE_ASC, PRICE_DESC, CREATED_ASC, CREATED_DESC
seoTitlestringSEO title
seoDescriptionstringSEO description
Example: Create a summer collection
bash
curl -X POST "https://api.finxa.store/api/collections" \
  -H "X-Finxa-Access-Token: fxat_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Summer Collection 2026",
    "description": "Lightweight essentials for the summer season.",
    "collectionType": "MANUAL",
    "sortOrder": "MANUAL"
  }'

Update Collection

PATCH/api/collections/:collectionId

Update collection fields.

Required scopes:write_collections

Delete Collection

DELETE/api/collections/:collectionId

Delete a collection. Products in the collection are not deleted.

Required scopes:write_collections

Add Product to Collection

POST/api/collections/:collectionId/products

Add one or more products to a manual collection.

Required scopes:write_collections
Example request
bash
curl -X POST "https://api.finxa.store/api/collections/col_abc123/products" \
  -H "X-Finxa-Access-Token: fxat_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{
    "productIds": ["prod_id_1", "prod_id_2"]
  }'

Remove Product from Collection

DELETE/api/collections/:collectionId/products/:productId

Remove a product from a collection.

Required scopes:write_collections

Orders

Orders represent customer purchases. Each order has line items, a status, payment information, and shipping details.

Order Statuses

ParameterTypeDescription
DRAFTstatusDraft order, not yet placed
PENDINGstatusOrder placed, awaiting payment
PAIDstatusPayment received
PROCESSINGstatusBeing prepared for shipment
FULFILLEDstatusShipped to customer
COMPLETEDstatusDelivered and finalized
CANCELLEDstatusOrder cancelled
REFUNDEDstatusFull refund issued
PARTIALLY_REFUNDEDstatusPartial refund issued

List Orders

GET/api/orders

Retrieve a paginated list of orders.

Required scopes:read_orders

Query Parameters

ParameterTypeDescription
pagenumberPage number. Default: 1
limitnumberItems per page. Default: 20
statusstringFilter by order status
Example request
bash
curl "https://api.finxa.store/api/orders?status=PAID&limit=10" \
  -H "X-Finxa-Access-Token: fxat_your_secret_key"

Get Order

GET/api/orders/:id

Retrieve a single order with all details.

Required scopes:read_orders

Create Draft Order

POST/api/orders/draft

Create a draft order with line items.

Required scopes:write_orders
Example: Create a draft order
bash
curl -X POST "https://api.finxa.store/api/orders/draft" \
  -H "X-Finxa-Access-Token: fxat_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{
    "customerId": "cust_abc123",
    "items": [
      { "variantId": "var_xyz789", "quantity": 2 }
    ],
    "shippingAddress": {
      "line1": "123 Main St",
      "city": "Manama",
      "country": "BH"
    }
  }'

Update Order Status

PATCH/api/orders/:id/status

Transition an order to a new status.

Required scopes:write_orders
Example: Mark as fulfilled
bash
curl -X PATCH "https://api.finxa.store/api/orders/order_abc123/status" \
  -H "X-Finxa-Access-Token: fxat_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{ "status": "FULFILLED" }'
ℹ️Not all status transitions are valid. The API enforces a state machine — for example, you can't go from COMPLETED back to DRAFT.

Complete Order

POST/api/orders/:id/complete

Mark a fulfilled order as completed.

Required scopes:write_orders

Update Order Items

PUT/api/orders/:id/items

Replace or adjust line items (exchange); inventory and totals update on save.

Required scopes:write_orders

Update Order Notes

PATCH/api/orders/:id/notes

Add or update internal notes on an order.

Required scopes:write_orders
Example request
bash
curl -X PATCH "https://api.finxa.store/api/orders/order_abc123/notes" \
  -H "X-Finxa-Access-Token: fxat_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{ "notes": "Customer requested gift wrapping" }'

Update Order Shipping

PATCH/api/orders/:id/shipping

Add or update tracking information.

Required scopes:write_orders
Example: Add tracking info
bash
curl -X PATCH "https://api.finxa.store/api/orders/order_abc123/shipping" \
  -H "X-Finxa-Access-Token: fxat_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{
    "trackingNumber": "1Z999AA10123456784",
    "trackingUrl": "https://track.example.com/1Z999AA10123456784",
    "trackingCarrier": "Aramex"
  }'

Customers

Customers represent people who have purchased from your store or that you've added manually. Each customer has a unique email per store.

List Customers

GET/api/customers

Retrieve a paginated list of customers with order counts and total spent.

Required scopes:read_customers

Query Parameters

ParameterTypeDescription
pagenumberPage number. Default: 1
limitnumberItems per page (max 100). Default: 20
searchstringSearch by email, first name, last name, or phone

Get Customer

GET/api/customers/:id

Retrieve a customer with their order history and total spent.

Required scopes:read_customers

Create Customer

POST/api/customers

Create a new customer.

Required scopes:write_customers

Request Body

ParameterTypeDescription
emailRequiredstringCustomer email address (unique per store)
firstNamestringFirst name
lastNamestringLast name
phonestringPhone number
notesstringInternal notes
addressobjectAddress: { line1, line2?, city, state?, country, postalCode? }
Example: Create a customer
bash
curl -X POST "https://api.finxa.store/api/customers" \
  -H "X-Finxa-Access-Token: fxat_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "firstName": "Ahmed",
    "lastName": "Al-Khalifa",
    "phone": "+97312345678",
    "address": {
      "line1": "Building 42, Road 3601",
      "city": "Manama",
      "country": "BH",
      "postalCode": "0360"
    }
  }'

Update Customer

PATCH/api/customers/:id

Update customer fields. Only send the fields you want to change.

Required scopes:write_customers

Delete Customer

DELETE/api/customers/:id

Delete a customer permanently.

Required scopes:write_customers
⚠️Deleting a customer doesn't delete their orders. Existing orders will show customerId: null.

Discounts

Create discount codes (percentage or fixed amount) that customers can apply at checkout. Discounts support usage limits, date ranges, minimum order amounts, and can be targeted to specific products or collections.

List Discounts

GET/api/discounts

Retrieve all discount codes.

Required scopes:read_discounts

Get Discount

GET/api/discounts/:id

Retrieve a single discount with product/collection associations.

Required scopes:read_discounts

Create Discount

POST/api/discounts

Create a new discount code.

Required scopes:write_discounts

Request Body

ParameterTypeDescription
titleRequiredstringInternal title for the discount
codeRequiredstringDiscount code customers will enter (unique per store)
typeRequiredstringPERCENTAGE or FIXED_AMOUNT
valueRequirednumberDiscount value (e.g. 10 for 10% or 5.000 for 5 BHD)
triggerstringCODE (default) or AUTOMATIC
appliesTostringORDER (default), PRODUCT, or COLLECTION
minimumOrderAmountnumberMinimum order total to apply. Default: 0
usageLimitnumberMax total uses (null = unlimited)
onePerCustomerbooleanLimit one use per customer. Default: false
startsAtstringISO 8601 start date
endsAtstringISO 8601 end date
productIdsstring[]Product IDs for product-level discounts
collectionIdsstring[]Collection IDs for collection-level discounts
Example: Create 15% off discount
bash
curl -X POST "https://api.finxa.store/api/discounts" \
  -H "X-Finxa-Access-Token: fxat_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Summer Sale",
    "code": "SUMMER15",
    "type": "PERCENTAGE",
    "value": 15,
    "minimumOrderAmount": 20,
    "startsAt": "2026-06-01T00:00:00Z",
    "endsAt": "2026-08-31T23:59:59Z"
  }'

Update Discount

PATCH/api/discounts/:id

Update a discount. Only send the fields you want to change.

Required scopes:write_discounts

Delete Discount

DELETE/api/discounts/:id

Delete a discount code.

Required scopes:write_discounts

Shipping

Manage shipping zones and rates. A shipping zone defines which countries/regions are eligible and what rates to charge.

List Shipping Zones

GET/api/shipping/zones

Retrieve all shipping zones with their rates.

Required scopes:read_shipping

Get Shipping Zone

GET/api/shipping/zones/:id

Retrieve a single shipping zone.

Required scopes:read_shipping

Create Shipping Zone

POST/api/shipping/zones

Create a new shipping zone with rates.

Required scopes:write_shipping
Example: Create a domestic shipping zone
bash
curl -X POST "https://api.finxa.store/api/shipping/zones" \
  -H "X-Finxa-Access-Token: fxat_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Bahrain Domestic",
    "countries": ["BH"],
    "rates": [
      { "name": "Standard Shipping", "price": 1.500, "minOrderAmount": 0 },
      { "name": "Free Shipping",     "price": 0,     "minOrderAmount": 25 }
    ]
  }'

Update Shipping Zone

PATCH/api/shipping/zones/:id

Update a shipping zone and its rates.

Required scopes:write_shipping

Delete Shipping Zone

DELETE/api/shipping/zones/:id

Delete a shipping zone.

Required scopes:write_shipping

Pages (CMS)

Create and manage static pages for your storefront — About Us, Contact, FAQ, Shipping Policy, etc. Pages support rich HTML content.

List Pages

GET/api/pages/:storeId

Retrieve all pages for your store.

Required scopes:read_content

Get Page

GET/api/pages/:storeId/:pageId

Retrieve a single page.

Required scopes:read_content

Create Page

POST/api/pages/:storeId

Create a new page.

Required scopes:write_content

Request Body

ParameterTypeDescription
titleRequiredstringPage title
handlestringURL slug (auto-generated from title if omitted)
bodystringRich HTML content
publishedbooleanWhether the page is published. Default: false
seoTitlestringSEO title
seoDescriptionstringSEO description
Example: Create an About page
bash
curl -X POST "https://api.finxa.store/api/pages/YOUR_STORE_ID" \
  -H "X-Finxa-Access-Token: fxat_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "About Us",
    "body": "<h2>Our Story</h2><p>We started in 2024 with a mission to...</p>",
    "published": true
  }'

Update Page

PUT/api/pages/:storeId/:pageId

Update a page.

Required scopes:write_content

Delete Page

DELETE/api/pages/:storeId/:pageId

Delete a page permanently.

Required scopes:write_content

Metafields & app storage

Attach your own data to products, orders and the store: one JSON value per (namespace, key), up to 64 KB. Use a namespace of your own so two apps never collide. Store-level metafields are your app's settings storage. Product metafield changes emit products/update, so other apps stay in sync.

💡Values are returned exactly as stored (any JSON). The optional type is a hint for consumers — json, string, number, boolean or date — and is inferred when omitted.
GET/api/products/{productId}/metafields?namespace=

Metafields of a product, optionally filtered by namespace.

Required scopes:read_products
PUT/api/products/{productId}/metafields/{namespace}/{key}

Create or replace one value — 201 on create, 200 on update.

Required scopes:write_products
POST/api/products/{productId}/metafields

Upsert up to 100 values in one call.

Required scopes:write_products
DELETE/api/products/{productId}/metafields/{namespace}/{key}

Remove one value.

Required scopes:write_products
PUT/api/orders/{id}/metafields/{namespace}/{key}

The same endpoints exist under /api/orders/{id}/metafields.

Required scopes:write_orders
PUT/api/metafields/{namespace}/{key}

Store-level values (your app's storage): GET/POST /api/metafields, GET/PUT/DELETE /api/metafields/{namespace}/{key}.

Required scopes:write_store
PUT /api/products/{productId}/metafields/erp/sku
json
{ "value": { "code": "ERP-001", "bin": "A-12" }, "type": "json" }

// → 201 on create, 200 on update
{ "success": true, "data": { "id": "…", "namespace": "erp", "key": "sku",
  "value": { "code": "ERP-001", "bin": "A-12" }, "type": "json", "app_id": "app_…",
  "created_at": "2026-09-21T08:00:00.000Z", "updated_at": "2026-09-21T08:00:00.000Z" } }

All endpoints of this resource in the reference →

Files & images

Product images and videos are uploaded straight to storage through a signed URL, then attached by URL. Allowed: JPEG, PNG, WebP, GIF, SVG, MP4, WebM.

POST/api/uploads/presign

Signed upload URL for { filename, contentType } → { uploadUrl, fileUrl, key }.

Required scopes:write_products
POST/api/products/{productId}/images

Attach an uploaded (or externally hosted) image: { src, alt?, position? }.

Required scopes:write_products
Upload an image in three steps
javascript
// 1. ask for a signed upload URL
const { data } = await (await api("/uploads/presign", { method: "POST",
  body: JSON.stringify({ filename: "hero.png", contentType: "image/png" }) })).json();

// 2. PUT the bytes straight to storage (no token needed on this request)
await fetch(data.uploadUrl, { method: "PUT", headers: { "Content-Type": "image/png" }, body: fileBytes });

// 3. attach it to the product by URL
await api(`/products/${productId}/images`, { method: "POST",
  body: JSON.stringify({ src: data.fileUrl, alt: "Hero", position: 0 }) });

All endpoints of this resource in the reference →

Store settings

Read the store's settings and update the merchant-editable ones. Payment secrets and the storefront lock password never leave the API for apps.

⚠️Apps can change name, language, SEO fields and analytics IDs. The store currency is changed from Markets (prices are converted there), and the storefront lock only from the dashboard (403 for apps).
GET/api/stores/me

The store: id, handle, name, currency, language, SEO, pixels, theme and more (secrets stripped).

Required scopes:read_store
PATCH/api/stores/me

Update settings: name, language, seoTitle, seoDescription, ga4MeasurementId, metaPixelId.

Required scopes:write_store
PATCH /api/stores/me
json
{ "name": "Sunglasses BHR", "seoTitle": "Sunglasses in Bahrain", "language": "ar", "ga4MeasurementId": "G-XXXX" }

All endpoints of this resource in the reference →

Inventory

Stock lives on the variant. Adjust by a delta for movements you record, or set an absolute quantity after a count; every change is logged with a reason and emits inventory_levels/update.

GET/api/inventory/{variantId}

Current stock and the last changes for a variant.

Required scopes:read_products
POST/api/inventory/{variantId}/adjust

Change stock by { delta, reason? } → { before, after }.

Required scopes:write_products
POST/api/inventory/{variantId}/set

Set stock to { quantity, reason? }.

Required scopes:write_products
GET/api/inventory/{variantId}/history

Full change history for a variant.

Required scopes:read_products
POST /api/inventory/{variantId}/adjust
json
{ "delta": -2, "reason": "Damaged in warehouse" }
// → { "success": true, "data": { "before": 5, "after": 3 } }  and an inventory_levels/update webhook

All endpoints of this resource in the reference →

Markets

A market groups countries with a currency, price list and shipping zones. Prices can be set per market; product publishing can be limited per market.

GET/api/markets

All markets with their countries and currency.

Required scopes:read_markets
POST/api/markets

Create a market.

Required scopes:write_markets
PATCH/api/markets/{id}

Update name, countries, currency settings.

Required scopes:write_markets
GET/api/markets/{id}/prices

Per-variant prices in this market.

Required scopes:read_markets
PATCH/api/markets/{id}/prices

Set prices for variants in this market.

Required scopes:write_markets
GET/api/market-publishing/{marketId}

Which products are published or excluded in a market.

Required scopes:read_markets

All endpoints of this resource in the reference →

Translations

Enable locales and translate products, collections, pages, menus and theme strings. Storefront requests in a locale get the translated fields automatically.

GET/api/translations/locales

Enabled locales and their status.

Required scopes:read_content
POST/api/translations/locales

Enable a locale.

Required scopes:write_content
GET/api/translations/resource/{resourceType}/{resourceId}

Translations of one resource (e.g. product/{id}) across locales.

Required scopes:read_content
PUT/api/translations

Write translations: { resourceType, resourceId, locale, fields }.

Required scopes:write_content
POST/api/translations/translate-all

Machine-translate everything missing into a locale (async job; poll /translate-all/{jobId}).

Required scopes:write_content

All endpoints of this resource in the reference →

Returns

Return requests move through approve → receive → process (restock and refund) or decline. Each step is its own call so warehouse and finance can act separately.

GET/api/returns

Returns, filterable by status.

Required scopes:read_orders
POST/api/returns

Open a return for an order's items.

Required scopes:write_orders
PATCH/api/returns/{id}/approve

Approve the request.

Required scopes:write_orders
PATCH/api/returns/{id}/receive

Mark the items as received.

Required scopes:write_orders
PATCH/api/returns/{id}/process

Restock and refund.

Required scopes:write_orders
PATCH/api/returns/{id}/decline

Decline with a reason.

Required scopes:write_orders

All endpoints of this resource in the reference →

Abandoned carts

Checkouts that were started but not completed. Recovery e-mails are sent by the platform on a schedule; you can list, inspect and re-send, and tune the settings.

GET/api/abandoned-cart

Abandoned checkouts, newest first.

Required scopes:read_orders
GET/api/abandoned-cart/{id}

One checkout with its items and recovery status.

Required scopes:read_orders
POST/api/abandoned-cart/{id}/send

Send (or re-send) the recovery e-mail now.

Required scopes:write_orders
GET/api/abandoned-cart/settings

Recovery settings: delay, discount, subject.

Required scopes:read_orders
PATCH/api/abandoned-cart/settings

Update recovery settings.

Required scopes:write_orders

All endpoints of this resource in the reference →

Tax

Tax-inclusive or exclusive pricing, and rates per country or region. The checkout applies the matching rate automatically.

GET/api/tax/settings

Tax settings of the store.

Required scopes:read_store
PATCH/api/tax/settings

Update tax settings.

Required scopes:write_store
GET/api/tax/rates

Configured tax rates.

Required scopes:read_store
POST/api/tax/rates

Add a rate for a country/region.

Required scopes:write_store
GET/api/tax/lookup?country=

The rate that applies to a destination.

Required scopes:read_store

All endpoints of this resource in the reference →

Domains

Connect custom domains to the storefront. Add the domain, point DNS as instructed, then verify; one domain is primary.

GET/api/domains

Connected domains and their verification status.

Required scopes:read_store
POST/api/domains

Add a domain.

Required scopes:write_store
POST/api/domains/{domainId}/verify

Verify DNS.

Required scopes:write_store
POST/api/domains/{domainId}/set-primary

Make it the primary domain.

Required scopes:write_store
GET/api/domains/instructions

The DNS records to set.

Required scopes:read_store

All endpoints of this resource in the reference →

Payment methods

Manual methods (cash on delivery, bank transfer) are fully manageable. Gateway credentials (Tap, Stripe, PayPal, Checkout.com) can be toggled and read without secrets.

⚠️Gateway secret keys are write-only and never returned. Toggling a gateway off keeps the keys.
GET/api/payment-methods/manual

Manual payment methods.

Required scopes:read_store
POST/api/payment-methods/manual

Add a manual method: { name, instructions, isActive }.

Required scopes:write_store
PATCH/api/payment-methods/manual/{id}

Update a manual method.

Required scopes:write_store
GET/api/payment-methods/tap

Gateway status (enabled, public key) — also /stripe, /paypal, /checkoutcom.

Required scopes:read_store
PATCH/api/payment-methods/tap/toggle

Enable or disable the gateway.

Required scopes:write_store

All endpoints of this resource in the reference →

Analytics

Read-only storefront traffic: sessions, page views, top pages and who is on the site right now.

GET/api/analytics/{storeId}/overview?days=

Sessions, views and conversion for the last N days.

Required scopes:read_analytics
GET/api/analytics/{storeId}/top-pages

Most viewed pages.

Required scopes:read_analytics
GET/api/analytics/{storeId}/live

Visitors active in the last few minutes.

Required scopes:read_analytics

All endpoints of this resource in the reference →

Blog

Posts with rich HTML bodies, cover images, tags and scheduling. Published posts appear on the storefront blog and in the Storefront API.

GET/api/blog/{storeId}

Posts (drafts included).

Required scopes:read_content
POST/api/blog/{storeId}

Create a post; set publishedAt in the future to schedule it.

Required scopes:write_content
PATCH/api/blog/{storeId}/{id}

Update a post.

Required scopes:write_content
DELETE/api/blog/{storeId}/{id}

Delete a post.

Required scopes:write_content
POST /api/blog/{storeId}
json
{ "title": "Summer drop", "handle": "summer-drop", "body": "<p>…</p>", "excerpt": "New frames.", "tags": ["news"],
  "isPublished": true, "publishedAt": "2026-09-22T09:00:00Z", "coverImage": "https://…/cover.jpg" }

All endpoints of this resource in the reference →

Menus are trees of items linking to collections, products, pages or URLs, with optional dynamic collection groups and mega-menu panels.

GET/api/navigation/{storeId}

Menus of the store.

Required scopes:read_content
POST/api/navigation/{storeId}

Create a menu with its items.

Required scopes:write_content
PUT/api/navigation/{storeId}/{menuId}

Replace a menu's title and items.

Required scopes:write_content
GET/api/navigation/resolve?store=&handle=

Public: a menu resolved to links (used by the storefront).

All endpoints of this resource in the reference →

Theme

Per-section overrides of the storefront theme settings (colors, layout options) without touching the theme itself.

GET/api/theme-overrides

Current overrides.

Required scopes:read_content
PUT/api/theme-overrides

Set overrides.

Required scopes:write_content
GET/api/theme-overrides/merged

Theme settings with overrides applied.

Required scopes:read_content
DELETE/api/theme-overrides

Remove all overrides.

Required scopes:write_content

All endpoints of this resource in the reference →

Reviews

Product reviews left by customers, with moderation (approve, hide, reply) and store-level settings.

GET/api/reviews/{storeId}?status=

Reviews, filterable by status.

Required scopes:read_products
PATCH/api/reviews/{storeId}/{id}

Approve, hide or reply to a review.

Required scopes:write_products
POST/api/reviews/{storeId}/bulk

Bulk moderation.

Required scopes:write_products
PUT/api/reviews/{storeId}/settings

Review settings (auto-approve, verified-only).

Required scopes:write_products

All endpoints of this resource in the reference →

Duties

Import duty rates per destination country, used to estimate duties at checkout for international orders.

GET/api/duties

Configured duty rates.

Required scopes:read_markets
POST/api/duties

Add a rate for a country.

Required scopes:write_markets
GET/api/duties/estimate?country=&subtotal=

Estimated duties for a destination and subtotal.

Required scopes:read_markets

All endpoints of this resource in the reference →

Team

Team members, roles and invitations. These endpoints are for dashboard sessions; app tokens cannot manage the team.

ℹ️Dashboard sessions only.
GET/api/team/members

Members and their roles.

POST/api/team/members

Invite a member by e-mail.

PATCH/api/team/members/{id}

Change a member's role or permissions.

All endpoints of this resource in the reference →

Import, export & invoices

Bulk product data in and out as CSV, and order invoices as PDF.

GET/api/csv/products/export

Products as CSV.

Required scopes:read_products
POST/api/csv/products/import

Import products from a CSV upload (async; returns a job id).

Required scopes:write_products
GET/api/invoices/{orderId}

PDF invoice for an order.

Required scopes:read_orders

All endpoints of this resource in the reference →

Client libraries & tools

There is no official SDK yet — the API is plain JSON over HTTPS, so a small client is all you need. Copy one of these, or generate a typed client from the OpenAPI document.

TypeScript / Node
typescript
// finxa.ts — a 30-line client: token, versioning, idempotency, pagination
const BASE = "https://api.finxa.store/api";
export class Finxa {
  constructor(private token: string) {}
  async request<T>(method: string, path: string, body?: unknown, idempotencyKey?: string): Promise<T> {
    const res = await fetch(BASE + path, {
      method,
      headers: {
        "X-Finxa-Access-Token": this.token,
        "X-Finxa-API-Version": "2026-09",
        ...(body ? { "Content-Type": "application/json" } : {}),
        ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
      },
      body: body ? JSON.stringify(body) : undefined,
    });
    if (res.status === 429) {                       // rate limited: wait and retry once
      await new Promise((r) => setTimeout(r, Number(res.headers.get("Retry-After") ?? 1) * 1000));
      return this.request(method, path, body, idempotencyKey);
    }
    const json = await res.json();
    if (!res.ok || json.success === false) throw new Error(typeof json.error === "string" ? json.error : JSON.stringify(json.error));
    return json as T;
  }
  get = <T>(path: string) => this.request<T>("GET", path);
  post = <T>(path: string, body: unknown, key?: string) => this.request<T>("POST", path, body, key);
  patch = <T>(path: string, body: unknown) => this.request<T>("PATCH", path, body);
  /** Walk a page/limit list to the end. */
  async *pages<T>(path: string, limit = 100) {
    for (let page = 1; ; page++) {
      const { data } = await this.get<{ data: T[] }>(`${path}${path.includes("?") ? "&" : "?"}page=${page}&limit=${limit}`);
      yield* data;
      if (data.length < limit) return;
    }
  }
}

const finxa = new Finxa(process.env.FINXA_TOKEN!);
for await (const p of finxa.pages<{ id: string; title: string }>("/products?status=ACTIVE")) console.log(p.title);
Python
python
import os, requests

BASE = "https://api.finxa.store/api"
H = {"X-Finxa-Access-Token": os.environ["FINXA_TOKEN"], "X-Finxa-API-Version": "2026-09"}

def get(path, **params):
    r = requests.get(BASE + path, headers=H, params=params, timeout=30)
    r.raise_for_status()
    return r.json()["data"]

def post(path, body, idempotency_key=None):
    h = {**H, "Content-Type": "application/json"}
    if idempotency_key: h["Idempotency-Key"] = idempotency_key
    r = requests.post(BASE + path, headers=h, json=body, timeout=30)
    r.raise_for_status()
    return r.json()["data"]

for order in get("/orders", status="PAID", limit=50):
    print(order["orderNumber"], order["total"])
Postman, Insomnia and generated clients
bash
# Import into Postman or Insomnia: File → Import → paste the URL
https://api.finxa.store/api/openapi.json

# Generate a typed client in any language (needs Java or Docker)
npx @openapitools/openapi-generator-cli generate \
  -i https://api.finxa.store/api/openapi.json -g typescript-fetch -o ./finxa-client
# other generators: python, go, java, csharp, php, ruby, kotlin, swift5, dart …

# TypeScript types only, no runtime
npx openapi-typescript https://api.finxa.store/api/openapi.json -o finxa-api.d.ts
💡Keep the token in an environment variable, send Idempotency-Key on anything that creates money or stock, and honour Retry-After on 429.

Scopes Reference

When creating a Custom App, you select which scopes it needs. Scopes control what resources the app can access. A write_ scope always includes the corresponding read_ scope.

ScopeAccessDescription
read_productsReadView products, variants, images, and inventory levels
write_productsRead + WriteCreate, update, and delete products, variants, and images
read_collectionsReadView collections and their products
write_collectionsRead + WriteCreate, update, and delete collections
read_ordersReadView orders, line items, and fulfillment status
write_ordersRead + WriteUpdate order status, add tracking, manage fulfillment
read_customersReadView customer profiles and addresses
write_customersRead + WriteCreate, update, and delete customers
read_discountsReadView discount codes and rules
write_discountsRead + WriteCreate, update, and delete discounts
read_shippingReadView shipping zones, rates, and rules
write_shippingRead + WriteCreate, update, and delete shipping zones and rates
read_contentReadView pages and CMS content
write_contentRead + WriteCreate, update, and delete pages
read_storeReadView store configuration, tax rates, and payment methods
write_storeRead + WriteUpdate store settings, tax rates, and payment methods
read_marketsReadView markets, price lists, and localization settings
write_marketsRead + WriteCreate, update, and delete markets and price lists
read_analyticsReadView storefront sessions, page views, and traffic data
read_webhooksReadView webhook subscriptions
write_webhooksRead + WriteCreate, update, and delete webhook subscriptions

API reference

Every endpoint of the live API, generated from the OpenAPI document the API builds from its own route table — it cannot list a route that does not exist, and new routes appear here on deploy. The guides above cover the main resources; use search (⌘K) to jump to any operation.

OpenAPI 3.1
bash
https://api.finxa.store/api/openapi.json

Loading the live endpoint list…

Quick Start Guide

Get up and running in 5 minutes. This example creates a product, then lists all products using Node.js.

quick-start.js
javascript
const API_URL = "https://api.finxa.store/api";
const TOKEN = "fxat_your_secret_key_here";

const headers = {
  "X-Finxa-Access-Token": TOKEN,
  "Content-Type": "application/json",
};

// 1. Create a product
async function createProduct() {
  const res = await fetch(`${API_URL}/products`, {
    method: "POST",
    headers,
    body: JSON.stringify({
      title: "My First Product",
      status: "ACTIVE",
      variants: [
        { title: "Default", price: 19.99, inventory: 50 }
      ],
    }),
  });
  const data = await res.json();
  console.log("Created:", data.data.id);
  return data.data;
}

// 2. List all products
async function listProducts() {
  const res = await fetch(`${API_URL}/products`, { headers });
  const data = await res.json();
  console.log(`Found ${data.data.pagination.total} products`);
  return data.data.products;
}

// 3. Update a product
async function updateProduct(productId) {
  const res = await fetch(`${API_URL}/products/${productId}`, {
    method: "PATCH",
    headers,
    body: JSON.stringify({
      description: "Updated via the API!",
      tags: ["api-managed"],
    }),
  });
  const data = await res.json();
  console.log("Updated:", data.data.title);
}

// Run it
const product = await createProduct();
await updateProduct(product.id);
await listProducts();
quick-start.py
python
import requests

API_URL = "https://api.finxa.store/api"
HEADERS = {
    "X-Finxa-Access-Token": "fxat_your_secret_key_here",
    "Content-Type": "application/json",
}

# Create a product
res = requests.post(f"{API_URL}/products", headers=HEADERS, json={
    "title": "My First Product",
    "status": "ACTIVE",
    "variants": [
        {"title": "Default", "price": 19.99, "inventory": 50}
    ],
})
product = res.json()["data"]
print(f"Created: {product['id']}")

# List all products
res = requests.get(f"{API_URL}/products", headers=HEADERS)
data = res.json()["data"]
print(f"Found {data['pagination']['total']} products")

# Update a product
res = requests.patch(f"{API_URL}/products/{product['id']}", headers=HEADERS, json={
    "description": "Updated via the API!",
    "tags": ["api-managed"],
})
print(f"Updated: {res.json()['data']['title']}")
F
Finxa Developers

Build powerful integrations with the Finxa Commerce API.