Every call to the Identity Inc. API is authenticated with an organization API key. Institutional callers can layer on request signing and an IP allowlist.
API keys
Send the key as a bearer token on every request:
# Production
curl https://api.identityinc.io/v1/tenant \
-H "Authorization: Bearer bi_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# Sandbox (separate deployment — use a bi_test_ key on this host only)
curl https://sandbox.identityinc.io/v1/tenant \
-H "Authorization: Bearer bi_test_xxxxxxxxxxxxxxxxxxxxxxxx"
| Environment | Prefix | Where the key comes from |
|---|---|---|
| Production | bi_… | Signup (email OTP, then Builder), or POST /v1/admin/api-keys |
| Sandbox | bi_test_… | POST /v1/billing/email/otp/* then POST /v1/billing/checkout on sandbox.identityinc.io (Builder). Not the marketing checkout. |
Only a SHA-256 hash is stored, so a key is shown once — at signup /
sandbox checkout, or when you mint one at POST /v1/admin/api-keys. If you
lose it, revoke and issue another; there is no recovery path.
The key identifies your organization. Everything it touches — accounts, people, audit entries, quota — is scoped to that organization and invisible to others.
Server-side only. A key in browser JavaScript is a public key. Call the API from your backend and expose your own endpoints to the browser.
| Missing or malformed header | 401 UNAUTHORIZED — Missing Bearer API key |
| Revoked, expired, or unknown key | 401 UNAUTHORIZED |
| Valid key, wrong scope | 403 FORBIDDEN |
Scopes
A key carries a set of scopes, chosen when it is created. Requesting an endpoint
outside them returns 403 — before any side effect, so a rejected call never
half-runs.
| Scope | Grants |
|---|---|
read | GET /v1/accounts/{did}, /v1/accounts/name-available, /v1/accounts/{did}/contact, /v1/tenant, /v1/metrics |
accounts_write | POST /v1/accounts/free, contact + OTP endpoints |
kyc_write | POST /v1/kyc/start, /v1/kyb/start, and GET /v1/kyc/status |
bind_write | POST /v1/bind/challenge, /v1/bind/confirm |
unlock_write | POST /v1/accounts/unlock |
compliance_export | /v1/compliance/* — export jobs, artifacts, audit verify |
webhook_manage | /v1/admin/webhooks* — register, list, deactivate, replay |
admin | /v1/admin/* — keys, signing, IP allowlist, country policy, DPA, suspend |
Issue narrow keys per workload. A key that only creates accounts cannot export your audit log if it leaks.
Note that GET /v1/kyc/status sits under kyc_write, not read — polling for
an approval needs the same scope that started the verification, so a read-only
key cannot drive the KYC flow on its own.
curl -X POST https://api.identityinc.io/v1/admin/api-keys \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"signup-service","scopes":["accounts_write","kyc_write","bind_write","unlock_write"]}'
Request signing (HMAC)
Optional, and off by default. Once enabled for your organization, every authenticated request must be signed or it is refused — enable it from a deploy that already signs, not before.
curl -X POST https://api.identityinc.io/v1/admin/signing/enable \
-H "Authorization: Bearer $ADMIN_KEY" # returns the signing secret, once
Add three headers:
| Header | Value |
|---|---|
X-BI-Timestamp | Current Unix time in milliseconds |
X-BI-Signature | Hex HMAC-SHA256 over the canonical string below |
X-BI-Signature-Version | 2 |
The canonical string (signature version 2) covers the method, the path and its query string, and the SHA-256 of the body rather than the body itself:
{timestampMs}.{METHOD}.{pathname}{canonicalQuery}.{sha256hex(rawBody)}
METHODis uppercase (POST, notpost).pathnameis the request path with no origin and no query (/v1/kyc/status).canonicalQueryis empty when the request has no query parameters. Otherwise it is?followed by every parameter: decode the query as a form (+is a space), re-encode each key and value with JavaScript'sencodeURIComponent, sort the pairs by encoded key and then by encoded value, and join them askey=valuewith&. Repeated keys are all kept.?b=2&a=z&a=1becomes?a=1&a=z&b=2.rawBodyis the exact bytes you transmit. Serialize once and send that same string, because re-serializing between signing and sending changes the hash.- For a request with no body, hash the empty string.
import { createHash, createHmac } from "node:crypto";
function canonicalQuery(query: string) {
const pairs = [...new URLSearchParams(query)].map(
([k, v]) => [encodeURIComponent(k), encodeURIComponent(v)],
);
if (pairs.length === 0) return "";
pairs.sort((a, b) =>
a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0,
);
return "?" + pairs.map(([k, v]) => `${k}=${v}`).join("&");
}
// `path` may include its query string: "/v1/kyc/status?personDid=…"
function signedHeaders(secret: string, method: string, path: string, body = "") {
const ts = Date.now();
const [pathname, query = ""] = path.split(/\?(.*)/s);
const bodyHash = createHash("sha256").update(body).digest("hex");
const canonical = `${ts}.${method.toUpperCase()}.${pathname}${canonicalQuery(query)}.${bodyHash}`;
return {
"X-BI-Timestamp": String(ts),
"X-BI-Signature": createHmac("sha256", secret).update(canonical).digest("hex"),
"X-BI-Signature-Version": "2",
};
}
const body = JSON.stringify({ accountDid }); // serialize once…
await fetch(baseUrl + "/v1/accounts/unlock", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...signedHeaders(secret, "POST", "/v1/accounts/unlock", body),
},
body, // …and send that same string
});
Signatures are single-use. Beyond the timestamp window, each signature is burned after one request, so a captured call cannot be replayed inside the window. Retrying a failed request means signing it again with a fresh timestamp — do not cache and resend headers.
Timestamps must be within 5 minutes of server time. Run NTP; clock drift on
one host presents as intermittent 401s that look like a key problem.
| Failure | Response |
|---|---|
| Header missing while signing is required | 401 — Signed requests required (X-BI-Timestamp, X-BI-Signature) |
| Timestamp unparseable | 401 — Invalid timestamp |
| Clock more than 5 minutes out | 401 — Request timestamp skew too large |
| Signature does not match | 401 — Invalid request signature |
X-BI-Signature-Version is anything other than 2 | 401 — Invalid request signature |
| v1 signature on a request with a query, after the sunset | 401 — Invalid request signature (v1 does not cover the query string; …) |
| Signature already used | 401 — Request signature already used (replay) |
Version 1 is deprecated for requests with a query string. Without
X-BI-Signature-Version the API treats the signature as version 1, which signs
{timestampMs}.{METHOD}.{pathname}.{sha256hex(rawBody)} and does not cover the
query. On a request with no query, v1 and v2 are the same string and v1 keeps
working. On a request with a query, v1 is accepted until 2026-12-31 00:00 UTC
and the response carries Deprecation: true and a Sunset header with the
cutoff. From then on such requests are refused with 401. The SDK
(@bound-identity/sdk) already signs version 2.
Outbound webhooks use the same two header names with a different canonical string —
{timestamp}.{rawBody}, no method, path, or body hash. Do not reuse this signing function to verify incoming webhooks. See Webhooks.
IP allowlists
Restrict a key to known egress addresses:
curl -X PUT https://api.identityinc.io/v1/admin/ip-allowlist \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"cidrs":["203.0.113.0/24","198.51.100.7/32"]}'
An allowlist fails closed: once set, every request from an address outside
it returns 403, including the one that would fix the list. Ask the API what
address it actually sees before you set one — behind NAT or an egress gateway it
is rarely the address you expect:
curl -s https://api.identityinc.io/v1/whoami # {"clientIp":"203.0.113.42"}
Run that from the server that will hold the key, not your laptop. Include every egress address your platform can use, not just the one that answered today.
Idempotency
POST /v1/accounts/free, POST /v1/kyc/start, and POST /v1/billing/checkout
each cost real money or resources. Send an Idempotency-Key so a retry after a
dropped connection or proxy timeout replays the original response instead of
charging again:
curl -X POST https://api.identityinc.io/v1/kyc/start \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: 7f3c1a9e-2b44-4c0d-9e51-2a8f0d6b1c33" \
-H "Content-Type: application/json" \
-d '{}'
- Use a fresh unique value per logical operation — a UUID per signup attempt.
- Keys are at most 255 characters and scoped to your organization.
- Replays are answerable for 24 hours; after that the key is purged and the same value starts a new operation.
- Reusing a key with a different body is rejected rather than silently returning the first response, because that is a client bug either way.
Correlation IDs
Pass x-correlation-id on any request and it is echoed back and written into
the server logs for that call. Supply your own request ID and a support
conversation can start from your trace instead of a timestamp guess. If you omit
it, one is generated.
See also
- Errors — every error code and what to do about it
- Webhooks — events, verification, retries
- Quickstart — first verified account in ten minutes
- OpenAPI spec — the machine-readable contract