Call the HTTP API from any wallet, fintech, marketplace, or bank backend.
New here? Start with Quickstart — one user from
unverified to a fully owned account, in curl.
| Guide | Covers |
|---|---|
| Quickstart | Sandbox-first end-to-end flow in curl |
| Authentication | API keys, scopes, HMAC request signing, IP allowlists, idempotency |
| Webhooks | Event catalogue, signature verification, retries, replay |
| Errors | Every error code, what causes it, and whether to retry |
| OpenAPI spec | The machine-readable contract |
This page covers what is left: base URLs, rate limits, calling the HTTP API, and the optional phone-first signup variant.
Call the API (any language)
There is no published client package yet, so call the HTTP API from your backend — it is the supported path and works from any language:
const baseUrl = process.env.BOUND_IDENTITY_URL!;
const apiKey = process.env.BOUND_IDENTITY_API_KEY!;
const res = await fetch(baseUrl + "/v1/kyc/start", {
method: "POST",
headers: {
Authorization: "Bearer " + apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
The full contract is the OpenAPI spec, published at https://identityinc.io/openapi.yaml.
Base URL. Pick one environment and keep the key on that same host — they do not share a database.
| Environment | Base URL | Key prefix | Notes |
|---|---|---|---|
| Sandbox | https://sandbox.identityinc.io | bi_test_… | Free test verifications, simulated chain. Mint a key with POST /v1/billing/checkout on this host — see Quickstart. |
| Production | https://api.identityinc.io | bi_… | Live chain; identity checks billed per verification. Keys from signup. |
Never point a bi_test_ key at production, or a production bi_ key at the
sandbox.
Before you configure an IP allowlist, ask the API what address it sees you as. NAT and egress gateways mean it is often not the address you expect, and an allowlist that does not match fails closed — every request returns 403.
curl -s https://api.identityinc.io/v1/whoami # {"clientIp":"203.0.113.42"}
Run it from the server that will hold your API key, not your laptop.
Request quota
Authenticated requests are counted per organization, not per IP, so running more application servers does not divide your allowance.
| Plan | Requests/minute |
|---|---|
| Builder | 120 |
| Growth | 600 |
| Institutional | 3,000 |
Every authenticated response carries your remaining budget, so you can back off before you are refused rather than after:
x-ratelimit-plan-limit: 600
x-ratelimit-plan-remaining: 594
x-ratelimit-plan-reset: 47
Exceeding it returns 429 with error: "RATE_LIMITED"; x-ratelimit-plan-reset
is the seconds until the window rolls over. Retry after that, with jitter. The
separate x-ratelimit-* headers describe a per-IP volumetric backstop set well
above every plan — under normal use the plan quota is the one you will meet.
Current values are also served at GET /v1/billing/plans under
limits.requestsPerMinute, so capacity planning does not depend on this table
staying current.
End-to-end (wallet) — KYC before free create
By default, organizations refuse /v1/accounts/free until a Person has an
approved verification. The shape below matches the Quickstart
curl flow.
const baseUrl = process.env.BOUND_IDENTITY_URL!;
const headers = {
Authorization: "Bearer " + process.env.BOUND_IDENTITY_API_KEY!,
"Content-Type": "application/json",
};
// 1. Person-first KYC (no on-chain account yet)
const start = await fetch(baseUrl + "/v1/kyc/start", {
method: "POST",
headers,
body: JSON.stringify({}),
}).then((r) => r.json());
// Open start.verificationUrl or mount start.accessToken.
// Sandbox: complete with a Shufti test ID — document + face, no OTP.
// Wait for the kyc.approved webhook, or poll:
const status = await fetch(
baseUrl + "/v1/kyc/status?personDid=" + encodeURIComponent(start.personDid),
{ headers },
).then((r) => r.json());
// status.kycCreateToken is issued when kycStatus === "approved"
// 2. Provisional account — only after KYC
const account = await fetch(baseUrl + "/v1/accounts/free", {
method: "POST",
headers,
body: JSON.stringify({
ownerPublicKey, // from passkey / WharfKit
personDid: start.personDid,
kycCreateToken: status.kycCreateToken,
}),
}).then((r) => r.json());
// 3. Bind — user signs with account key
const challenge = await fetch(baseUrl + "/v1/bind/challenge", {
method: "POST",
headers,
body: JSON.stringify({
accountDid: account.accountDid,
personDid: start.personDid,
}),
}).then((r) => r.json());
const signature = await session.signArbitrary(challenge.payloadToSign);
await fetch(baseUrl + "/v1/bind/confirm", {
method: "POST",
headers,
body: JSON.stringify({
accountDid: account.accountDid,
personDid: start.personDid,
nonce: challenge.message.nonce,
signature,
}),
});
// 4. Unlock — finalize ownership
await fetch(baseUrl + "/v1/accounts/unlock", {
method: "POST",
headers,
body: JSON.stringify({ accountDid: account.accountDid }),
});
Whether free creation is open at all, and whether it is KYC-gated, is a policy on your organization. Defaults are KYC-first; ask support if you need a change.
Phone SMS OTP (optional contact proof)
This is not part of hosted KYC. On the sandbox, KYC is Shufti document + face with a test ID — no OTP. Phone OTP is a separate contact-proof path some organizations enable.
It is off by default — contact us rather than building against it
speculatively. When enabled, confirming an SMS code yields a short-lived
phoneProofToken that /v1/accounts/free can accept.
const start = await fetch(baseUrl + "/v1/phone/otp/start", {
method: "POST",
headers,
body: JSON.stringify({ phone: "+233244123456" }),
}).then((r) => r.json());
// Deliver the SMS code from the user's handset into confirm — never expect
// the code in the API response on sandbox or production.
const confirm = await fetch(baseUrl + "/v1/phone/otp/confirm", {
method: "POST",
headers,
body: JSON.stringify({ challengeId: start.challengeId, code: userEnteredCode }),
}).then((r) => r.json());
await fetch(baseUrl + "/v1/accounts/free", {
method: "POST",
headers,
body: JSON.stringify({
ownerPublicKey,
phoneProofToken: confirm.phoneProofToken,
}),
});
Environment variables
In the application that calls us:
# The base URL your signup API key authenticates against
BOUND_IDENTITY_URL=https://api.identityinc.io
BOUND_IDENTITY_API_KEY=bi_...
Both are server-side only. A key reachable from the browser is a public key — proxy through your own backend and never ship it to a client bundle.
Further reading
- Quickstart — first verified account, in curl
- Authentication — keys, scopes, signing, idempotency
- Webhooks — events, verification, retries
- Errors — error codes and retry guidance
- OpenAPI spec — HTTP contract