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.
Who this is for
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.
Redirect URI rules
https:// (only http://localhost is allowed, for local development). Register every environment's callback URL up front.2. The authorization flow
Inkress uses the Authorization Code flow with PKCE. The full sequence:
- Generate a PKCE
code_verifierand itscode_challenge(S256). - Redirect the merchant to the Inkress authorize URL.
- The merchant approves the requested scopes on the consent screen.
- Inkress redirects back to your
redirect_uriwith a one-timecode. - Your server exchanges the
code+code_verifierfor tokens.
Step 1 — Build the authorize URL
// 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):
// 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 }{
"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
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.
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.Theft detection
4. Revoking access
Merchants can revoke your app any time from Settings → Connected apps. You can also revoke a token yourself:
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.
| Scope | Grants | Endpoints (all paths under /api/v1) |
|---|---|---|
| orders:read | View orders, line items, order details | GET /orders · /order_lines · /order_details |
| orders:write | Create and update orders | POST/PUT /orders · /order_lines · /order_details |
| customers:read | View customers + addresses (PII) | GET /users · /addresses |
| customers:write | Create and update customers + addresses | POST/PUT /users · /addresses |
| products:read | View the catalogue | GET /products · /variants · /categories · /product_tags |
| products:write | Create and update the catalogue | POST/PUT /products · /variants · /categories · /product_tags |
| payouts:read | View payout history & status | GET /financial_requests |
| payouts:create | Submit payout requests (Inkress still approves) | POST /financial_requests |
| financial_accounts:read | View payout destinations / bank accounts (sensitive) | GET /financial_accounts |
| wallet:read | View wallet balance, account summary, and this app's net contribution | POST /merchants/account/balances · POST /merchants/account/contribution |
| merchant_profile:read | View the merchant profile | GET /merchants/{merchant_id} |
| merchant_profile:write | Update merchant profile fields | PUT /merchants/{merchant_id} |
| webhooks:manage | Register/manage this app's own webhook subscriptions | GET/POST/PUT/DELETE /webhook_urls |
| payments:read | Reserved — not yet available (grants no data access) | — |
| offline_access | Receive 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:readgrants view of the merchant (GET /merchants/{id}) but not list — there is no endpoint to enumerate merchants. payments:readis reserved — it currently grants no data access (raw ledger/transaction data is intentionally not exposed over OAuth). Don't request it.webhooks:managelets 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_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.
// 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.
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:
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.
// 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.
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."
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.
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
- Connect — authorize / token exchange. Store
access_token,refresh_token,merchant_id, and yourwhsec_. - Subscribe —
POST /webhook_urlsfor the events you care about. - Write idempotently — every mutating call sends an
Idempotency-Key; failure-and-retry never duplicates. - React — verify the webhook HMAC with your
whsec_, apply the change to your local projection (or just render live with?app=me). - 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:
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.
invalid_grant
The code/refresh token is expired, already used, or the PKCE verifier doesn't match.
invalid_redirect_uri
The redirect_uri doesn't exactly match a registered URI.