INOPAY
Back to developers

REST API v1 reference

REST endpoints under /v1/. UTF-8 JSON responses. OAuth 2.0 Bearer authentication. Cursor pagination. Full OpenAPI 3.1 spec available for download.

OpenAPI 3.1 specification

The full spec, ready to import into Postman or Insomnia. Updated on every release.

OpenAPI 3.1

The full spec, ready to import into Postman or Insomnia. Updated on every release.

Download openapi.yaml

Authentication

All requests require a Bearer access token. Exchange your client keys (sk_test_ or sk_live_ prefix) for a short-lived token via /v1/auth/token. Scopes are explicit: kyc:read, kyc:write, orders:write, audit:read, webhooks:manage.

Rotation: secrets can be rotated without downtime via the dashboard. The old secret remains valid 24 h after a new one is issued.

cURL
# 1. Exchange client credentials for an access token
curl -X POST https://api.getinopay.com/v1/auth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "client_credentials",
    "client_id": "ino_client_8XK9R2",
    "client_secret": "sk_live_4bX9...PqW2",
    "scope": "kyc:read orders:write audit:read"
  }'

# Response 200 OK
# {
#   "access_token": "eyJhbGciOiJFZERTQSIs...",
#   "token_type": "Bearer",
#   "expires_in": 900,
#   "scope": "kyc:read orders:write audit:read"
# }

# 2. Authenticated calls
curl -X GET https://api.getinopay.com/v1/orders/ord_9Pk2X \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSIs..."
Python
import os
import requests

# Exchange credentials for a Bearer token
resp = requests.post(
    "https://api.getinopay.com/v1/auth/token",
    json={
        "grant_type": "client_credentials",
        "client_id": os.environ["INOPAY_CLIENT_ID"],
        "client_secret": os.environ["INOPAY_CLIENT_SECRET"],
        "scope": "kyc:read orders:write",
    },
    timeout=5,
)
resp.raise_for_status()
token = resp.json()["access_token"]

# Use the Bearer for subsequent calls
order = requests.get(
    "https://api.getinopay.com/v1/orders/ord_9Pk2X",
    headers={"Authorization": f"Bearer {token}"},
    timeout=5,
).json()
JavaScript
// Node 20+ / Browser fetch
const tokenRes = await fetch('https://api.getinopay.com/v1/auth/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    grant_type: 'client_credentials',
    client_id: process.env.INOPAY_CLIENT_ID,
    client_secret: process.env.INOPAY_CLIENT_SECRET,
    scope: 'kyc:read orders:write',
  }),
});
const { access_token } = await tokenRes.json();

const orderRes = await fetch(
  'https://api.getinopay.com/v1/orders/ord_9Pk2X',
  { headers: { Authorization: `Bearer ${access_token}` } },
);
const order = await orderRes.json();

Request / response format

UTF-8 JSON bodies. ISO 8601 dates (e.g. 2026-04-25T14:32:00Z, always UTC). Amounts in XOF cents (integers). Pseudonymous identifiers (ino_xxx, ord_xxx, att_xxx).

# Example POST body — amounts in XOF cents (entiers)
{
  "rcpt_to": "sgi_partner_001",
  "kyc_attestation_id": "att_4XK9RZ",
  "instrument": "SNTS.BRVM",
  "side": "buy",
  "qty": 10,
  "limit_price_cents": 1250000,        // 12 500 XOF
  "submitted_at": "2026-04-25T14:32:00Z",
  "client_metadata": {
    "internal_ref": "BO-2026-04-25-0001"
  }
}

# Example successful response (201 Created)
{
  "id": "ord_9Pk2X",
  "status": "routed",
  "rcpt_to": "sgi_partner_001",
  "instrument": "SNTS.BRVM",
  "side": "buy",
  "qty": 10,
  "limit_price_cents": 1250000,
  "filled_qty": 0,
  "average_fill_price_cents": null,
  "created_at": "2026-04-25T14:32:00.142Z",
  "updated_at": "2026-04-25T14:32:00.142Z"
}

Idempotency

Idempotency-Key header mandatory on all mutating POSTs. UUID v4 recommended. Responses are cached for 24 h: a retry with the same key returns the original response (same HTTP code).

# Both POSTs produce one order — second returns the cached 201
curl -X POST https://api.getinopay.com/v1/orders \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: 6c4b0b6e-1c2a-4d09-9b3a-7d1f4e5a6c2d" \
  -H "Content-Type: application/json" \
  -d @order.json

# Retry within 24h with the same key:
curl -X POST https://api.getinopay.com/v1/orders \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: 6c4b0b6e-1c2a-4d09-9b3a-7d1f4e5a6c2d" \
  -H "Content-Type: application/json" \
  -d @order.json
# → 201 Created (same body, same id, replayed from cache)

Pagination

Opaque cursor pagination: pass ?cursor=<value>&limit=50 (max 200). The X-Cursor-Next header contains the next page cursor (empty when done).

# First page
GET /v1/orders?limit=50

# Response headers
HTTP/1.1 200 OK
X-Cursor-Next: eyJzIjoib3JkXzlQazJYIiwidCI6MTc2NDA4MjMyMH0
X-Cursor-Prev:

# Next page
GET /v1/orders?limit=50&cursor=eyJzIjoib3JkXzlQazJYIiwidCI6MTc2NDA4MjMyMH0

Rate limiting

1000 req/min in sandbox, 10000 req/min in production (per client_id). Headers X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix timestamp). Beyond: 429 Too Many Requests with Retry-After header.

# Headers returned on every API call
HTTP/1.1 200 OK
X-RateLimit-Limit: 10000
X-RateLimit-Remaining: 9847
X-RateLimit-Reset: 1764082380

# When exceeded
HTTP/1.1 429 Too Many Requests
Retry-After: 27
Content-Type: application/json

{
  "code": "rate_limit_exceeded",
  "message": "Rate limit reached. Retry in 27 seconds.",
  "request_id": "req_a3f2b9c1"
}

Major endpoints

Route overview. Detailed schemas (parameters, error codes) live in the OpenAPI spec.

MethodPathDescriptionScope
POST/v1/auth/tokenExchange client keys → Bearer access token.
GET/v1/kyc/attestations/{id}Fetch an attestation and its status.kyc:read
POST/v1/kyc/attestationsCreate an Ed25519-signed KYC attestation.kyc:write
GET/v1/sgi/partnersPublic list of approved SGI partners.public
POST/v1/ordersSubmit an order routed to an approved SGI (rcpt_to).orders:write
GET/v1/orders/{id}Current order state, fills included.orders:read
POST/v1/webhooks/endpointsRegister an endpoint for event delivery.webhooks:manage
GET/v1/audit/snapshots/{date}Fetch an anchored Merkle snapshot.audit:read
Full OpenAPI spec

Errors

Standard HTTP codes. Normalized JSON body: { code, message, details, request_id }. Provide request_id when contacting support.

Sample error
# 401 Unauthorized — invalid or expired token
{ "code": "unauthorized", "message": "Access token expired", "request_id": "req_a3f2b9c1" }

# 403 Forbidden — token lacks scope
{ "code": "forbidden", "message": "Missing scope: orders:write", "request_id": "req_a3f2b9c2" }

# 404 Not Found — unknown resource
{ "code": "not_found", "message": "Order ord_unknown not found", "request_id": "req_a3f2b9c3" }

# 422 Unprocessable Entity — validation failure
{
  "code": "validation_error",
  "message": "qty must be a positive integer",
  "details": [{ "field": "qty", "rule": "min", "value": 0 }],
  "request_id": "req_a3f2b9c4"
}

# 429 Too Many Requests — see Rate limiting above
# 500 Internal Server Error — transient; retry with same Idempotency-Key
{ "code": "internal_error", "message": "An unexpected error occurred", "request_id": "req_a3f2b9c5" }