Take one user from unverified to a fully owned blockchain account. Every call
below is curl — no SDK required.
Start on the sandbox. Production runs live identity checks and live chain writes. The sandbox uses free test verifications against the real provider and a simulated chain.
Sandbox vs production
| Sandbox | Production | |
|---|---|---|
| Base URL | https://sandbox.identityinc.io | https://api.identityinc.io |
| API key prefix | bi_test_… | bi_… |
| How to get a key | POST /v1/billing/checkout on the sandbox URL (below) | Signup on the marketing site |
| Chain | Simulated (chain:mock) | Live Vaulta/EOS (chain:live) |
| Identity checks | Real Shufti flow, test account — free | Real Shufti, production account — billed by provider |
| Provider callback host | sandbox.identityinc.io | production API host |
Confirm which environment you hit:
curl -s https://sandbox.identityinc.io/v1/health
# {"ok":true,...,"mode":{"chain":"mock","kyc":"shuftipro"}}
curl -s https://api.identityinc.io/v1/health
# {"ok":true,...,"mode":{"chain":"live","kyc":"shuftipro"}}
Never send a bi_test_ key to production, or a bi_ key to the sandbox — they
are different deployments with different databases.
0. Get a sandbox API key
The marketing checkout at identityinc.io provisions production keys. For sandbox, call checkout on the sandbox host:
curl -X POST https://sandbox.identityinc.io/v1/billing/checkout \
-H "Content-Type: application/json" \
-d '{
"planId": "builder",
"email": "you@example.com",
"orgName": "My App",
"tenantSlug": "myapp-dev",
"acceptTerms": true
}'
The response includes apiKey once (prefix bi_test_). Store it server-side.
export BI_URL=https://sandbox.identityinc.io
export BI_KEY=bi_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
When you are ready for live traffic, sign up at
checkout, switch BI_URL to
https://api.identityinc.io, and use the bi_… key from signup.
The model in one minute
Three objects, and the whole API is about the relationship between them.
| Person | A verified human. did:web:…. Created by KYC, never by you directly. |
| Account | A blockchain account. did:antelope:…. Starts provisional until unlock. |
| Binding | The cryptographic link between the two, signed by the user's own key. |
The flow is always the same shape: verify the person → create the account → bind them → unlock. An account is only fully the user's after unlock.
DID prefixes are validated, so passing an accountDid where a personDid
belongs fails with VALIDATION_ERROR rather than doing something surprising.
1. Verify the person
Start KYC with no account — this is person-first, and it is what the default organization policy requires before any free account is created.
curl -X POST "$BI_URL/v1/kyc/start" \
-H "Authorization: Bearer $BI_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{}'
{
"personDid": "did:web:…:people:550e8400-e29b-41d4-a716-446655440000",
"provider": "shuftipro",
"applicantId": "...",
"accessToken": "...",
"verificationUrl": "https://app.shuftipro.com/...",
"levelName": "basic"
}
Send the user to verificationUrl, or use accessToken to mount the provider's
onsite SDK in your own UI. Identity documents go to the provider, not to us —
we hold hashes and the decision.
On the sandbox, complete the flow with a
Shufti test ID — not a real
passport. Test documents are free on the sandbox's Shufti test account. On
production, every start is a paid verification; always send an
Idempotency-Key so a network retry does not bill you twice. See
Idempotency.
No OTP in sandbox KYC. Verification is document + face with a test ID — BoundIdentity does not enable Shufti phone OTP for KYC. Phone/email OTP endpoints are optional contact proof (off on sandbox); you do not need them for the path below.
Keep personDid. It is how you refer to this human from now on.
2. Wait for approval
Register a webhook for kyc.approved — that is the intended
path, and it avoids a polling loop entirely.
If you must poll:
curl -s "$BI_URL/v1/kyc/status?personDid=$PERSON_DID" \
-H "Authorization: Bearer $BI_KEY"
{
"personDid": "did:web:...",
"kycStatus": "approved",
"kycLevel": "basic",
"kycProvider": "shuftipro",
"country": "GH",
"kycCreateToken": "...",
"expiresInSec": 900
}
kycCreateToken appears only once kycStatus is approved. It is
one-time and expires in 15 minutes: it authorizes exactly one free account
creation, and replaying it is rejected. Fetch it immediately before step 3
rather than storing it.
If status stays pending after you finished the provider UI on sandbox,
contact support with your x-correlation-id — do not start a second
verification while waiting.
This endpoint needs the kyc_write scope, not read — polling for an approval
requires the same scope that started the verification.
3. Create the account
curl -X POST "$BI_URL/v1/accounts/free" \
-H "Authorization: Bearer $BI_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"ownerPublicKey": "PUB_K1_...",
"personDid": "'"$PERSON_DID"'",
"kycCreateToken": "'"$KYC_CREATE_TOKEN"'",
"deviceFingerprint": "optional-device-hash",
"preferredName": "myuser1"
}'
Returns 201 with accountDid, the chain account name, and sponsored
resources. On sandbox the chain is simulated — nothing is spent on a live
network.
ownerPublicKey is the user's key — from a passkey or WharfKit session. We
never see the private key. Optional fields worth sending:
| Field | Why |
|---|---|
preferredName | Desired chain name, [a-z1-5.]{1,12}. Check GET /v1/accounts/name-available first |
deviceFingerprint | Optional opaque device signal your app already collects |
activePublicKey | If you want a separate active key from the owner key |
email | Claimant contact; stored hashed |
Sandbox does not require contact OTP for free-create. Production may
additionally require emailProofToken and phoneProofToken from the OTP
endpoints — a 403 here with KYC_REQUIRED, or a validation error naming a
proof token, means your organization's policy expects one.
4. Bind the person to the account
The user proves they hold the account key by signing a challenge.
curl -X POST "$BI_URL/v1/bind/challenge" \
-H "Authorization: Bearer $BI_KEY" \
-H "Content-Type: application/json" \
-d '{"accountDid":"'"$ACCOUNT_DID"'","personDid":"'"$PERSON_DID"'"}'
Returns { "message": { "nonce": "...", ... }, "payloadToSign": "..." }.
Have the user sign payloadToSign verbatim with the key matching
ownerPublicKey — session.signArbitrary(payloadToSign) in WharfKit, or your
passkey flow. Then confirm:
curl -X POST "$BI_URL/v1/bind/confirm" \
-H "Authorization: Bearer $BI_KEY" \
-H "Content-Type: application/json" \
-d '{
"accountDid": "'"$ACCOUNT_DID"'",
"personDid": "'"$PERSON_DID"'",
"nonce": "'"$NONCE"'",
"signature": "SIG_K1_..."
}'
Nonces are single-use and expire. NONCE_INVALID means request a fresh
challenge; INVALID_SIGNATURE means the signature did not verify against the
account's registered key — usually the payload was modified or the wrong key
signed.
This issues a binding credential, Ed25519-signed by our issuer. On sandbox the
issuer DID is did:web:sandbox.identityinc.io (see /.well-known/did.json).
Production uses a different issuer — credentials do not verify across
environments.
5. Unlock
Finalize the account so the user holds it outright.
curl -X POST "$BI_URL/v1/accounts/unlock" \
-H "Authorization: Bearer $BI_KEY" \
-H "Content-Type: application/json" \
-d '{"accountDid":"'"$ACCOUNT_DID"'"}'
ACCOUNT_NOT_PROVISIONAL here usually means a previous call already
succeeded — check GET /v1/accounts/{did} and read lifecycle before
retrying.
Check your work
curl -s "$BI_URL/v1/accounts/$ACCOUNT_DID" -H "Authorization: Bearer $BI_KEY"
Before you go live
Worth getting right on sandbox before production traffic:
- Verifications are billed per start on production. Keep sending an
Idempotency-Keyon/v1/kyc/start— see Idempotency. - Free account creation is rate limited on production. Build the retry path
against
429before you load-test.
Switch BI_URL / BI_KEY to production only when you are ready for live
checks.
Next
- Authentication — scopes, request signing, IP allowlists, idempotency
- Webhooks — stop polling
- Errors — every code and what to do about it
- Integration guide — rate limits, phone-first signup, SDK
- OpenAPI spec — full contract