12iD documentation

12id Platform API — integration guide

Base URL: https://api.12id.com (production, from go-live); https://api.12id.naziharitsolution.com (staging). Machine-readable spec: GET /openapi.json (also published here, rendered as the API reference).

Credentials

Caller Header Obtained from
Your backend Authorization: Bearer 12id_sk_… 12id operations (one or more keys per organisation)
Your app (the SDK) Authorization: Bearer 12id_wt_… your backend, via POST /v1/wallets

Never ship an API key in an app. The wallet token is per installation and can be revoked with DELETE /v1/wallets/{id}.

What an API key can do. An API key runs your integration: connections, credential offers, proof requests and wallets, plus reading everything (catalogue, templates, webhook endpoint and deliveries). Setup is done in the 12id console, not with an API key:

With an API key, those routes answer 403 forbidden. A leaked key therefore cannot write to the ledger or redirect your webhooks.

The asynchronous model

Every command returns immediately (202) with the exchange record. The holder answers minutes or hours later, so progress arrives by webhook (recommended) or by polling GET routes. Pass externalId on commands to get your own reference back on every event. Send an Idempotency-Key header on POSTs you may retry; a replay with the same key and body returns the original response (Idempotent-Replayed: true), the same key with a different body answers 422, and a retry that arrives while the first request is still running answers 409 idempotency_in_progress (retry after Retry-After seconds). A request that failed with a 5xx can be retried with the same key and runs again. Keys are kept for 24 hours.

Lists. Every list route (GET /v1/connections, /v1/credentials, /v1/proofs, /v1/wallets, /v1/webhook/deliveries, /v1/proof-templates, /v1/schemas, /v1/credential-definitions) answers { "items": [...], "total": 312, "page": 1, "pageSize": 25 }, newest first (createdAt descending); the order cannot be changed.

Rate limits. Each API key may make 300 requests per minute, unless 12id raised the limit for your organisation. Each wallet token (the SDK) may make 60 requests per minute, and 3 mediator invitations per 10 minutes. Above a limit the API answers 429 rate_limited with a Retry-After header (seconds). Wait that long, then retry.

Wallet lifecycle.

Responses that carry a secret are never stored for replays:

Exchange States you will see
connection request-received → response-sent → completed
credential offer-sent → request-received → credential-issued (holder has it) → done (holder acknowledged); abandoned if the holder never answers (24 h)
proof request-sent → presentation-received → done (verified + disclosed set); abandoned

Typical flow

  1. In the console: publish the schema and its credential definition (once per credential type/version), set the webhook endpoint, and create proof templates. Your backend reads the result with GET /v1/credential-definitions and GET /v1/proof-templates.
  2. POST /v1/wallets for each app installation; hand walletToken to the app.
  3. App/SDK: GET /v1/ledger (genesis URL + sha256 + signature; cacheable for 5 minutes), GET /v1/wallet/mediator-invitation (once, at first launch). The Android SDK does both itself; see the Android SDK guide.
  4. POST /v1/connections/invitations → show invitationUrl as a link or QR code → connection.state_changed: completed.
  5. POST /v1/credentials/offer → credential.state_changed.
  6. POST /v1/proofs/request with a template id → proof.state_changed: done with verified and disclosed.

Attribute rules

The full guide, with encoding recipes for dates, amounts and yes/no values, validity periods and wallet recovery: credential-model.md.

Set the endpoint in the console (https only). The signing secret is shown once, when the endpoint is created or the secret is rotated. GET /v1/webhook returns the current URL and event filter. Each event is POSTed as:

{ "id": "…", "type": "proof.state_changed", "createdAt": "…", "tenantId": "…", "data": { "id": "…", "state": "done", "previousState": "presentation-received", "externalId": "…", "verified": true, "disclosed": { "employee": { "name": "…" } } } }

Verify X-12id-Signature: t=<unix>,v1=<hex>[,v1=<hex>] where each v1 = HMAC-SHA256(secret, "<t>.<raw body>"), and reject old timestamps. Accept the event if any v1 matches. For 24 hours after a secret rotation, every delivery carries two signatures, the new secret’s first and the previous secret’s second. That way your receiver keeps working while you switch it to the new secret.

const parts = header.split(',').map((p) => p.split('='))
const t = parts.find(([k]) => k === 't')?.[1]
const expected = Buffer.from(crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'))
const ok = Math.abs(Date.now() / 1000 - Number(t)) < 300 &&
  parts.some(([k, v]) => k === 'v1' && v.length === expected.length && crypto.timingSafeEqual(Buffer.from(v), expected))

Answer 2xx within 10 s. Failures are retried with exponential backoff (5 s doubling, capped at 1 h) for 12 attempts, about 2.5 hours in total, and then dead-lettered. See GET /v1/webhook/deliveries?status=dead, and retry dead deliveries from the console. Every attempt, retries included, goes to the endpoint URL set at that moment. Deliveries for different organisations are sent in parallel, so a slow receiver only delays its own events. Delivery order is not guaranteed: use state and updatedAt.

wallet.sync_requested

Sent when the mediator is holding messages for one of your wallets (data.walletId, data.externalId). Send a silent push to that installation with your own FCM/APNs setup; the app calls the SDK’s sync(). At most one per wallet every 30 s. It may also arrive while the app is open, or right after first launch — treat it as “sync now”, not as “a credential arrived”.

Biometric checks

Optional per tenant: before a credential is issued or a proof result is released, the holder passes a face liveness check and a face match against a reference image you provide. You configure your own vendor account (AWS Rekognition Face Liveness) in the console, switch the gate on, and choose where it applies:

When a policy applies, send the reference image with the command:

POST /v1/credentials/offer
{ "connectionId": "…", "credentialDefinitionId": "…", "attributes": { … }, "biometric": { "referenceImage": "<base64 JPEG or PNG, ≤ 5 MB, one face>" } }

Without it the call answers 422 biometric_reference_required. The image is used for this exchange only and deleted as soon as the check is decided (or expires); it is never returned, logged or sent in a webhook.

What happens next:

You are the controller of the reference images and must have the user’s consent for the check.

record.erased

Sent after an exchange’s or a connection’s personal data was erased on request (data.recordType, data.recordId, data.erasedAt, data.initiator: api_key, tenant_member or operator). Delete your own copies of that record’s data. Erasure by the retention period sends no event.

Data protection

No personal data is written to the ledger. Credentials live only on the holder’s device. The mediator sees encrypted messages and their timing only. The platform keeps exchange state and, for the retention period (90 days by default, set in the console), the attribute values, disclosed values and webhook bodies; then it erases them automatically. GET /v1/data-retention returns the period in force.

To erase on a user’s request:

The record stays, with erasedAt set and its personal data null. GET /v1/erasures lists every erasure made on request. Erasing does not invalidate an issued credential: it stays usable until its expiresAt.

The full description, for your privacy and legal teams: data-protection.md.