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, or POST /v1/admin/api-keys |
| Sandbox | bi_test_… | POST https://sandbox.identityinc.io/v1/billing/checkout (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 two headers:
| Header | Value |
|---|---|
X-BI-Timestamp | Current Unix time in milliseconds |
X-BI-Signature | Hex HMAC-SHA256 over the canonical string below |
The canonical string is four dot-joined fields — note that it covers the method and path, and the SHA-256 of the body rather than the body itself:
{timestampMs}.{METHOD}.{path}.{sha256hex(rawBody)}
METHODis uppercase (POST, notpost).pathis the path as sent, with no origin and no query string rewriting.rawBodyis the exact bytes you transmit. Serialize once and send that same string — 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 signedHeaders(secret: string, method: string, path: string, body = "") {
const ts = Date.now();
const bodyHash = createHash("sha256").update(body).digest("hex");
const canonical = `${ts}.${method.toUpperCase()}.${path}.${bodyHash}`;
return {
"X-BI-Timestamp": String(ts),
"X-BI-Signature": createHmac("sha256", secret).update(canonical).digest("hex"),
};
}
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 |
| Signature already used | 401 — Request signature already used (replay) |
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