OAuth 2.1 Integration

Let merchants connect their Inkress account to your app with OAuth 2.1 and PKCE. Your app receives scoped, revocable access tokens — never the merchant's password or API keys.

1. Register your app

From your organisation dashboard, go to Developer → Developer Apps and click Register App. You'll provide a name, description, logo, contact email, one or more redirect URIs, and the scopes your app needs.

New apps start in Pending review. Inkress reviews each app before it can issue tokens — you'll receive your client_id and client_secret immediately at registration (the secret is shown once — store it in your server's secret manager), but the authorize endpoint will reject the app until it's approved.

2. The authorization flow

Inkress uses the Authorization Code flow with PKCE. The full sequence:

  1. Generate a PKCE code_verifier and its code_challenge (S256).
  2. Redirect the merchant to the Inkress authorize URL.
  3. The merchant approves the requested scopes on the consent screen.
  4. Inkress redirects back to your redirect_uri with a one-time code.
  5. Your server exchanges the code + code_verifier for tokens.

Step 1 — Build the authorize URL

oauth-start.js
// Generate the PKCE pair (Web Crypto)
function base64url(bytes) {
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
const challenge = base64url(new Uint8Array(digest));

// Persist `verifier` + `state` (you verify them on the callback).
const params = new URLSearchParams({
  response_type: "code",
  client_id: "inkid_your_client_id",
  redirect_uri: "https://yourapp.com/oauth/callback",
  scope: "orders:read wallet:read offline_access",
  state: crypto.randomUUID(),
  code_challenge: challenge,
  code_challenge_method: "S256",
});

// Send the merchant to Inkress to approve access:
window.location.href = "https://inkress.com/oauth/authorize?" + params.toString();

Always send a unique state and verify it on the callback to prevent CSRF. Include offline_access if you need a refresh token.

Step 2 — Exchange the code for tokens

After the merchant approves, Inkress redirects to https://yourapp.com/oauth/callback?code=inkc_...&state=.... Exchange the code from your server (theclient_secret must never touch the browser):

exchange-code.js (server-side)
// Runs on your server — client_secret must never reach the browser.
const res = await fetch("https://api.inkress.com/api/v1/hooks/oauth/token", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    grant_type: "authorization_code",
    code,                                   // from the callback query string
    redirect_uri: "https://yourapp.com/oauth/callback",
    client_id: "inkid_your_client_id",
    client_secret: process.env.INKRESS_CLIENT_SECRET,
    code_verifier: verifier,                // the PKCE verifier you stored
  }),
});

const tokens = await res.json();
// => { access_token, token_type, expires_in, refresh_token, scope, merchant_id }
response
{
  "access_token": "inka_...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "inkr_...",
  "scope": "orders:read wallet:read offline_access",
  "merchant_id": 183
}

The access token (inka_…) lasts 1 hour(expires_in: 3600); the refresh token (inkr_…) lasts 30 days. merchant_id is the merchant this token is bound to — store it (see Identifying the connected merchant below).

Step 3 — Call the API

call-api.js
const res = await fetch("https://api.inkress.com/api/v1/orders", {
  headers: { Authorization: "Bearer " + accessToken },
});

const orders = await res.json();

The token is scoped to the merchant who installed your app and limited to the scopes they granted. A call outside those scopes returns 401.

3. Refreshing tokens

Access tokens expire after 1 hour. If you requested offline_access, use the refresh token to get a new pair. Refresh tokens are single-use and rotating — each refresh returns a new refresh token and invalidates the old one. Store the newest one.

refresh.js (server-side)
const res = await fetch("https://api.inkress.com/api/v1/hooks/oauth/token", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    grant_type: "refresh_token",
    refresh_token: storedRefreshToken,      // always the newest one
    client_id: "inkid_your_client_id",
    client_secret: process.env.INKRESS_CLIENT_SECRET,
  }),
});

const tokens = await res.json();
// Contains a NEW refresh_token — persist it and discard the old one.

4. Revoking access

Merchants can revoke your app any time from Settings → Connected apps. You can also revoke a token yourself:

revoke.js (server-side)
await fetch("https://api.inkress.com/api/v1/hooks/oauth/revoke", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    token: accessOrRefreshToken,            // an inka_… or inkr_… token
    client_id: "inkid_your_client_id",
    client_secret: process.env.INKRESS_CLIENT_SECRET,
  }),
});

Scopes

Request the minimum your integration needs — merchants see every scope on the consent screen, and fewer scopes means higher install conversion.

ScopeGrantsEndpoints (all paths under /api/v1)
orders:readView orders, line items, order detailsGET /orders · /order_lines · /order_details
orders:writeCreate and update ordersPOST/PUT /orders · /order_lines · /order_details
customers:readView customers + addresses (PII)GET /users · /addresses
customers:writeCreate and update customers + addressesPOST/PUT /users · /addresses
products:readView the catalogueGET /products · /variants · /categories · /product_tags
products:writeCreate and update the cataloguePOST/PUT /products · /variants · /categories · /product_tags
payouts:readView payout history & statusGET /financial_requests
payouts:createSubmit payout requests (Inkress still approves)POST /financial_requests
financial_accounts:readView payout destinations / bank accounts (sensitive)GET /financial_accounts
wallet:readView wallet balance, account summary, and this app's net contributionPOST /merchants/account/balances · POST /merchants/account/contribution
merchant_profile:readView the merchant profileGET /merchants/{merchant_id}
merchant_profile:writeUpdate merchant profile fieldsPUT /merchants/{merchant_id}
webhooks:manageRegister/manage this app's own webhook subscriptionsGET/POST/PUT/DELETE /webhook_urls
payments:readReserved — not yet available (grants no data access)
offline_accessReceive a refresh token for long-lived access
  • Everything is merchant-scoped. A token only ever returns the data of the single merchant who installed your app — never other merchants, even within the same organisation.
  • Granularity is per action. e.g. merchant_profile:read grants view of the merchant (GET /merchants/{id}) but not list — there is no endpoint to enumerate merchants.
  • payments:read is reserved — it currently grants no data access (raw ledger/transaction data is intentionally not exposed over OAuth). Don't request it.
  • webhooks:manage lets your app register its own webhook subscriptions (/webhook_urls) — scoped to your app, it can never overwrite the merchant's. See Receiving webhooks below.

Identifying the connected merchant

There is no /me endpoint. The token-exchange response includes the merchant_id the token is bound to — store it alongside the tokens. Then read the profile (requires merchant_profile:read):

merchant-profile.js
// merchant_id comes from the /token response you stored at connect time.
const res = await fetch(`https://api.inkress.com/api/v1/merchants/${merchantId}`, {
  headers: { Authorization: "Bearer " + accessToken },
});
const { result: merchant } = await res.json();
// => { id, username, title, logo, currency_code, ... }

Most records also carry the merchant inline (e.g. an order's merchant_id / merchant_name_frozen), so apps with orders:read can recover it without storing it.

Requesting a payout

Payouts use three scopes together. List the merchant's accounts to choose a destination (financial_accounts:read), submit the request (payouts:create), then track it (payouts:read). Inkress still reviews and approves every payout — an app can never approve its own.

payout.js (server-side)
// 1. Pick the destination bank account + source wallet.
const accounts = await fetch("https://api.inkress.com/api/v1/financial_accounts", {
  headers: { Authorization: "Bearer " + accessToken },
}).then((r) => r.json()).then((d) => d.result.entries);

const wallet = accounts.find((a) => a.type === "inkress_wallet");
const bank   = accounts.find((a) => a.type === "bank_account" && a.active);

// 2. Submit the payout request (status starts pending; Inkress approves).
await fetch("https://api.inkress.com/api/v1/financial_requests", {
  method: "POST",
  headers: { Authorization: "Bearer " + accessToken, "Content-Type": "application/json" },
  body: JSON.stringify({
    type: 1,                       // 1 = payout
    source_id: wallet.id,
    destination_id: bank.id,
    total: 5000,
    currency_code: "JMD",
  }),
});

Receiving webhooks

With webhooks:manage, register your app's own webhook subscriptions for the connected merchant. They're scoped to your app — you only manage your own, and can never overwrite the merchant's.

register-webhook.js (server-side)
await fetch("https://api.inkress.com/api/v1/webhook_urls", {
  method: "POST",
  headers: { Authorization: "Bearer " + accessToken, "Content-Type": "application/json" },
  body: JSON.stringify({ url: "https://yourapp.com/inkress/webhook", event: "orders.paid" }),
});

Inkress signs each delivery with your app's webhook secret(whsec_…) — issued once at app registration, rotatable via POST /oauth_clients/{id}/rotate_webhook_secret. Verify it:

verify-webhook.js (server-side)
import crypto from "node:crypto";

// IMPORTANT: HMAC over the RAW request body, not a re-serialized object.
function verify(rawBody, signatureHeader, whsec) {
  const expected = crypto.createHmac("sha256", whsec).update(rawBody).digest("base64");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}

// headers: X-Inkress-Webhook-Signature, X-Inkress-Webhook-Event, X-Inkress-Webhook-ID
// Read merchant_id from the payload to route — one whsec covers all your merchants.

Headless integration pattern

You don't have to keep a full local copy of every Inkress record. Three primitives let you maintain a thin, attribution-scoped cache (or none at all if traffic is light).

?app=me — filter to what your app created

Every record an app creates through the API is automatically tagged with your oauth_client_id (polymorphic app_records). Append ?app=me to any list endpoint to narrow to your app's slice. Without it, the token still sees the merchant's full data — some apps (analytics, accounting) want exactly that.

my-orders.js
// Only orders your app placed for this merchant
const orders = await fetch("https://api.inkress.com/api/v1/orders?app=me", {
  headers: { Authorization: "Bearer " + accessToken },
}).then((r) => r.json());

?app=me is the only accepted value; arbitrary client_id values are not honoured (the id always comes from your bearer token).

Idempotency-Key — retry writes safely

Send Idempotency-Key: <your-uuid> on POST/PUT/DELETE. A retry with the same key and same body replays the stored response; Inkress never re-executes the write. Replays carry Idempotency-Replay: true. Same key with a different body returns 409 idempotency_key_reuse_with_different_payload. Stored for 24h.

idempotent-order.js
import { randomUUID } from "node:crypto";

const key = randomUUID();          // persist this alongside your draft order
await fetch("https://api.inkress.com/api/v1/orders", {
  method: "POST",
  headers: {
    Authorization: "Bearer " + accessToken,
    "Content-Type": "application/json",
    "Idempotency-Key": key,        // same key on every retry of THIS order
  },
  body: JSON.stringify({ /* ...order... */ }),
});

updated_since — resilient reconciliation

After a webhook outage (or as a nightly catch-up), pull deltas since your last successful processed timestamp. Combine with ?app=me for an app-scoped sweep. Schedule it on a cron and you can treat webhooks as "fast path, best-effort" instead of "must not miss one."

reconcile.txt
GET /api/v1/orders?app=me&updated_since=2026-05-29T10:15:00Z
Authorization: Bearer inka_…

POST /merchants/account/contribution — net value to the wallet

Show your users how much your integration has actually moved through the merchant's wallet. net_contribution sums credits − debits over the wallet entries traceable to orders your app created. Optional currency_code narrows to one currency; omit it for the all-currencies figure. Requires wallet:read.

contribution.txt
POST /api/v1/merchants/account/contribution
Authorization: Bearer inka_…
Content-Type: application/json

{ "currency_code": "USD" }

→ { "state": "ok", "result": {
    "oauth_client_id": 17,
    "merchant_id": 183,
    "currency_code": "USD",
    "net_contribution": 6994.74,
    "entry_count": 5,
    "last_activity_at": "2026-05-29T22:31:04Z"
  } }

Contribution ≠ balance. Merchant payouts you didn't initiate don't subtract from this number — it's "what value did my activity put in," not "what's mine to draw on." For cash-on-hand, use POST /merchants/account/balances. The app id is taken from your bearer token; you can never query another app's contribution, even on the same merchant.

Putting it together

  1. Connect — authorize / token exchange. Store access_token, refresh_token, merchant_id, and your whsec_.
  2. SubscribePOST /webhook_urls for the events you care about.
  3. Write idempotently — every mutating call sends an Idempotency-Key; failure-and-retry never duplicates.
  4. React — verify the webhook HMAC with your whsec_, apply the change to your local projection (or just render live with ?app=me).
  5. Reconcile — a periodic ?app=me&updated_since=<last_sync> sweep catches anything a webhook missed.

You never re-derive payment truth. Inkress remains the system of record; you hold what's useful to you, sized to your needs.

Discovery

Inkress publishes RFC 8414 metadata so SDKs can resolve every endpoint automatically:

discovery.js
const metadata = await fetch(
  "https://api.inkress.com/.well-known/oauth-authorization-server"
).then((r) => r.json());
// => { authorization_endpoint, token_endpoint, revocation_endpoint, scopes_supported, ... }

Common errors

unauthorized_client

Your app isn't approved (or is paused/revoked) yet.

Newly registered apps must be approved by Inkress before they can issue tokens. Check the app status in your Developer Apps dashboard.

invalid_grant

The code/refresh token is expired, already used, or the PKCE verifier doesn't match.

Authorization codes are single-use and short-lived. Refresh tokens are single-use. Re-run the authorize flow if the code expired.

invalid_redirect_uri

The redirect_uri doesn't exactly match a registered URI.

Matching is exact — protocol, host, path, and query must be identical to a URI you registered.