Building Your App: Runtime & Data Access

Your app is a web service you host. Once a merchant installs it, the dashboard embeds it in a sandboxed iframe and hands it a one-time launch code. Your app's server exchanges that code (with its client credentials) for a short-lived access token, then reads the shop's data from the Merchant API. Your app never talks to the platform database directly.

This page covers what happens after install: how your app is embedded, how it authenticates (OAuth authorization-code style), and how it reads shop data.

The embed model

When a merchant opens your app (App Store → Installed → Manage), the dashboard loads your Redirect URL in a sandboxed iframe and appends:

Param Example Meaning
code a1b2c3… (64 hex) One-time launch code, valid ~60s, single use. Exchange it for a token.
api https://api.bitcommerz.com Base URL for the OAuth token endpoint and data calls.
shop_id 117 The shop your app is serving (context only — never trust it for auth).

So your app is opened at, e.g.:

https://your-app.example.com/?code=a1b2c3…&api=https://api.bitcommerz.com&shop_id=117

A long-lived token never travels in the URL — only a short, single-use code.

Authenticating: exchange the code for a token

On your server, exchange the code for tokens at the OAuth endpoint, using the client_id / client_secret you received when your app was approved:

POST {api}/app-oauth/token
Content-Type: application/json

{ "grant_type": "authorization_code",
  "code": "<code from the iframe URL>",
  "client_id": "app_…",
  "client_secret": "<your secret>" }

Response

{ "token_type": "Bearer",
  "access_token": "eyJhbGciOi…",   // use this on data calls (~10 min)
  "refresh_token": "eyJhbGciOi…",  // swap for a new access token later
  "expires_in": 600,
  "scope": "read_orders read_analytics" }
  • The access token is short-lived (~10 min) and carries your granted scopes.
  • The refresh token (long-lived) gets a new access token via grant_type=refresh_token with the same client credentials.
  • Codes are single-use — a reused or expired code returns 400.

Keep the secret on your server

Your client_secret and both tokens must stay server-side. The launch code is the only thing that appears in the browser, and it is useless without your secret. If your app is opened with no code, show a "open me from your dashboard" message rather than failing.

The access token

  • A JWT bound to your app (aud = client_id) and scoped to one shop (shopId), carrying your granted scopes.
  • Revoked on uninstall: every data call re-checks that your app is still active on the shop, so access stops the moment a merchant uninstalls — you don't wait for expiry.
  • Treat any 401 as "get a fresh token" (refresh, or re-open from the dashboard for a new code).

Reading shop data: the Merchant API

Call the Merchant API at the api base with the token as a Bearer credential. Every response is automatically scoped to the token's shop — you never pass a shop_id to the API yourself.

Orders

GET {api}/merchant-order?perPage=50&currentPage=1
Authorization: Bearer {token}
Query Default Notes
perPage 10 Page size.
currentPage 1 1-based page number.

Response

{
  "data": [
    {
      "id": 143588,
      "order_number": "1783048837902",
      "status": "Order Placed",
      "payment_status": "unpaid",
      "grand_total": 4580,
      "created_at": "2026-07-01T14:40:53.572Z"
    }
  ],
  "perPage": 50,
  "currentPage": 1,
  "totalPage": 3,
  "totalResult": 134
}

Aggregate on your side for reports (totals, status breakdown, trends). Use totalResult for the true order count and page through with currentPage when you need every row.

Scopes still apply

You can only read what your app requested at submission time (e.g. read_orders). See App Configuration → Permissions.

Running without a merchant present (client_credentials)

Everything above is the embed flow: a merchant opens your app, you get a launch code, you act inside their session. If your backend needs to work when nobody is looking — a nightly sync, a scheduled report — there is a separate server-to-server flow on the platform API.

POST {platform_api}/api/v2/app-oauth/token
Content-Type: application/json

{ "grant_type": "client_credentials",
  "client_id": "<client_id>",
  "client_secret": "<client_secret>" }

Returns an app token valid for 1 hour. client_credentials is the only grant this endpoint accepts.

GET {platform_api}/api/v2/app-api/orders?shop_id=117&limit=50
Authorization: Bearer {app_token}

Two differences that matter:

  • The token is not bound to a shop. You pass shop_id on every call, and the platform re-checks that your app is installed and active on that shop each time. Passing a shop you are not installed on fails.
  • It is a different host and a different base path from the Merchant API used by the embed flow. Do not mix the two tokens — a merchant-api access token will not authenticate here, and vice versa.

Scopes work the same way: the route requires the scope you requested at submission (read_orders for the route above).

Narrow surface

Orders read is the only route exposed on this flow today. If you need more, use the embed flow, or tell us what you need.

A minimal app (Node, no framework)

The whole app: exchange the code for a token, call the Merchant API, render.

const http = require("http");
const { URL } = require("url");
const { CLIENT_ID, CLIENT_SECRET } = process.env;

http.createServer(async (req, res) => {
  const url = new URL(req.url, "http://localhost");
  const code = url.searchParams.get("code");
  const api = url.searchParams.get("api");

  if (!code || !api) {
    res.writeHead(200, { "content-type": "text/html" });
    return res.end("<p>Open this app from your BitCommerz dashboard.</p>");
  }

  // 1) Swap the one-time code for a short-lived access token (server-side).
  const t = await fetch(`${api}/app-oauth/token`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      grant_type: "authorization_code",
      code, client_id: CLIENT_ID, client_secret: CLIENT_SECRET,
    }),
  }).then((r) => r.json());

  // 2) Read orders with the access token — no direct DB access.
  const { data = [], totalResult = 0 } = await fetch(
    `${api}/merchant-order?perPage=1000&currentPage=1`,
    { headers: { Authorization: `Bearer ${t.access_token}` } }
  ).then((r) => r.json());

  const revenue = data.reduce((s, o) => s + Number(o.grand_total || 0), 0);
  res.writeHead(200, { "content-type": "text/html" });
  res.end(`<h1>Order Report</h1>
    <p>${totalResult} orders · ৳${revenue.toLocaleString()} revenue</p>`);
}).listen(process.env.PORT || 8080);

Serve it so the dashboard can iframe it — don't set X-Frame-Options: DENY (the platform embeds you with a sandbox; a hard frame-deny blanks the panel).

End-to-end lifecycle

Build            Host a web service (any stack) that reads ?code &api and
                 renders your UI.
  |
Submit           Partner Panel -> Apps -> Create App. Set App URL and Redirect
                 URL to your service, request only the scopes you use.
                 (status: pending)
  |
Approve          A super-admin publishes it. You receive OAuth client_id +
                 client_secret (secret shown once) and the app enters the
                 App Store catalog.
  |
Install          Merchant installs from their App Store. Free installs
                 instantly; paid goes through checkout.
  |
Run              Dashboard embeds your Redirect URL with code + api + shop_id.
                 Your server exchanges the code and calls the Merchant API.

See App Distribution & Lifecycle for the publishing side, and App Configuration for the submission fields.

Checklist

  • [ ] App reads code + api from the iframe URL, server-side.
  • [ ] Exchanges the code for a token at /app-oauth/token with your client_id / client_secret (kept server-side).
  • [ ] All shop data comes from the Merchant API with the Bearer access token — no direct database access.
  • [ ] Graceful "open from dashboard" state when there's no code.
  • [ ] 401 handled as "get a fresh token" (refresh, or re-open for a new code).
  • [ ] Only the scopes you actually use are requested at submission — data routes reject tokens missing the required scope.
  • [ ] Your App URL is iframe-embeddable (no X-Frame-Options: DENY).