Docs
SDK

Native SDK (iOS, Android)

Read a passport or identity card chip in your own app, with short-lived SDK tokens minted by your backend and the verdict delivered to your backend

The native SDK reads the chip of a passport or an identity card in your iOS or Android app and has the Verifiables API verify it: passive authentication against the State's CSCA, the holder's data from the signed DG1 and DG11, and a proof that the chip itself was on the phone (Chip Authentication run by our server, relayed by the phone). The same token also reaches the 2D-DOC and France Identité justificatif routes.

Your app never holds an API key. It works the way identity verification SDKs usually do:

  1. Your backend mints a token for each verification session, with its secret API key.
  2. Your app uses the token to read the chip and call the verification routes. The token lives 10 minutes by default and gives at most 3 verdicts.
  3. Your backend gets the verdict from us, by a signed webhook and by GET /v1/sdk/tokens/:id. It never takes the verdict from the app, because the person being verified holds the phone.

Installation

The SDK is delivered to clients with a contract, as one archive per platform and version; it is not on a public repository. Ask your Verifiables contact for the current version.

iOS (iOS 15 or later, Xcode 16): verifiables-sdk-ios-<version>.zip holds the Swift package VerifiablesSDK. Unzip it next to your project and add it as a local package (Xcode: File > Add Package Dependencies > Add Local), or in a Package.swift:

.package(path: "../VerifiablesSDK"),
// targets
.product(name: "VerifiablesNFC", package: "VerifiablesSDK"), // chip reading
.product(name: "VerifiablesAPI", package: "VerifiablesSDK"), // API client, SDK tokens
.product(name: "VerifiablesMRZ", package: "VerifiablesSDK"), // MRZ camera scanner

Its only external dependency, OpenSSL 3 (a prebuilt binary package), is fetched by Xcode.

Your app's Info.plist and entitlements:

<!-- Info.plist -->
<key>NFCReaderUsageDescription</key>
<string>NFC reads the chip of your passport or identity card.</string>
<key>NSCameraUsageDescription</key>
<string>The camera reads the machine readable zone of your document.</string>
<key>com.apple.developer.nfc.readersession.iso7816.select-identifiers</key>
<array>
  <string>A0000002471001</string>
  <string>A0000002472001</string>
  <string>00000000000000</string>
</array>

<!-- Entitlements: Signing & Capabilities > Near Field Communication Tag Reading -->
<key>com.apple.developer.nfc.readersession.formats</key>
<array>
  <string>TAG</string>
  <string>PACE</string>
</array>

Android (Android 7.0, API 24, or later; Kotlin 2.1 or later, Java 17): verifiables-sdk-android-<version>.zip holds verifiables-maven/, a Maven repository with the AARs and their POMs. Copy it into your project and declare it:

// settings.gradle.kts
dependencyResolutionManagement {
  repositories {
    google()
    mavenCentral()
    maven { url = uri("verifiables-maven") }
  }
}

// app/build.gradle.kts
dependencies {
  implementation("com.verifiables.sdk:api:0.1.0") // brings com.verifiables.sdk:nfc
  implementation("com.verifiables.sdk:mrz:0.1.0") // MRZ camera scanner
}

The AARs declare their permissions (NFC, internet, camera) in their manifests, which merge into yours. Third-party components and their licences are listed in THIRD_PARTY_NOTICES.md in each archive. On Android, text recognition is Google ML Kit's on-device model: it never sends the images or the text read, and it sends Google anonymous usage metrics.

Backend: mint a token

POST /v1/sdk/tokens

Authenticated with a secret API key. Create a key with the sdk scope in the dashboard (API keys): it can mint tokens and read their results, nothing else. A test key mints test tokens, whose verifications are recorded and never billed.

curl -X POST https://api.verifiables.com/v1/sdk/tokens \
  -H "Authorization: Bearer oat_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{
    "scopes": ["nfc_identity"],
    "subject": "onboarding-77421",
    "appId": "com.example.app",
    "maxVerifications": 3,
    "ttlSeconds": 600,
    "policy": { "requirePresence": true }
  }'
FieldRule
scopesRequired. Any of nfc_identity, twoddoc, twoddoc_compare (comparison with the document text, runs OCR), fi_justificatif. Your organization must have the matching product, otherwise 422 scope_not_enabled.
subjectOptional, up to 128 characters of A-Z a-z 0-9 . _ : @ -. Your own reference for the session, echoed in results and webhooks. Use an opaque id, not a name or an email.
appIdOptional. Your iOS bundle identifier or Android package name. The SDK sends it in X-Verifiables-App-Id; a token minted with an appId is refused from another app.
maxVerificationsOptional, 1 to 50, default 3. Billable verdicts the token can give. A retried NFC session counts once.
ttlSecondsOptional, 60 to 3600, default 600.
policy.requirePresenceOptional, default true. POST /nfc-identity/verify answers invalid when the chip did not prove its presence, so a copy of someone else's chip files cannot pass. Set false only if you read presence yourself.

Response 201:

{
  "id": "sdkt_01JAQ4X7Y3M9V2K8T5R6N0P1QZ",
  "token": "vst_live_Q3a1o4Hk9rWm0sD2pX7bN5cV8yL6tE1uJ0gF4hK2zA8",
  "mode": "live",
  "scopes": ["nfc_identity"],
  "subject": "onboarding-77421",
  "appId": "com.example.app",
  "maxVerifications": 3,
  "policy": { "requirePresence": true },
  "expiresAt": "2026-10-22T14:32:10.000Z",
  "expiresIn": 600
}

Keep id with your session. Give token and expiresIn to your app, through your own authenticated endpoint. We store only a hash of the token: it is shown once, here.

App: use the token

The SDK takes a token provider, a closure that asks your backend for a token. It keeps the token in memory only, fetches a new one when under 60 seconds remain, and makes sure 120 seconds remain before an NFC read starts, so no token is fetched while the card is on the phone. On a 401 it asks for one new token and retries once.

// iOS: Swift Package VerifiablesSDK
let tokens = SdkTokenProvider {
    let t = try await MyBackend.verifiablesSdkToken()
    return SdkToken(value: t.token, expiresIn: t.expiresIn)
}
let client = VerifiablesClient(baseURL: URL(string: "https://api.verifiables.com")!, tokens: tokens)

var options = ChipReadOptions()
options.presence = try await client.presenceConfig()
let read = try await ChipReader().read(accessKey: .can("123456"), options: options)
let verification = try await client.verifyChip(read) // for display only
// Android: com.verifiables.sdk:nfc and :api
val tokens = SdkTokenProvider {
  myBackend.verifiablesSdkToken().let { SdkToken.expiringIn(it.token, it.expiresIn) }
}
val client = VerifiablesClient("https://api.verifiables.com", tokens, appId = packageName)

The result the app receives is for display. Decide on your backend.

Opening the chip: MRZ capture or CAN

The chip opens with a key printed on the document. A French identity card since 2021 also has a 6-digit CAN on its front, which is enough. A passport, and any card without a CAN, opens with three fields of its machine readable zone (MRZ): document number, birth date, expiry.

The SDK reads the MRZ with the camera, on the phone. A reading is returned only when the check digits of the three fields agree and the same values came out of two frames in a row, so the key the chip receives is right. Nothing read is logged, stored or sent to Verifiables.

// iOS: VerifiablesMRZ, a full-screen scanner (SwiftUI; MrzScannerViewController for UIKit)
.fullScreenCover(isPresented: $scanning) {
    MrzScannerView { result in
        scanning = false
        accessKey = result.chipAccessKey // or .can("123456")
    } onCancel: { scanning = false }
}
// Android: com.verifiables.sdk:mrz
private val scanMrz = registerForActivityResult(ScanMrz()) { result ->
  if (result != null) key = ChipAccessKey.Mrz(result.documentNumber, result.dateOfBirth, result.dateOfExpiry)
}
// scanMrz.launch(Unit); or ChipAccessKey.Can("123456")

The scanner screen is a dimmed camera with a band for the zone, one instruction line, a light button and cancel. Its texts (French by default, English provided) and colours can be replaced. Apps with their own camera screen use MrzFrameAnalyzer instead, on both platforms.

Routes a token reaches

RouteScope
POST /nfc-identity/presence/start, POST /nfc-identity/presence/finishnfc_identity (10 presence starts per token)
POST /nfc-identity/verifynfc_identity
POST /2ddoc/verify, POST /2ddoc/scan, GET /2ddoc/statustwoddoc
POST /2ddoc/comparetwoddoc_compare
POST /documents/fi-justificatif/verify, POST /documents/fi-justificatif/verify-qrfi_justificatif

Nothing else: a token cannot read verification statuses, templates, mandates or any other data of your organization. Send it in the Authorization: Bearer header only; a token in a query string is refused (400 token_in_query).

Errors

StatuserrorMeaning
401invalid_tokenUnknown, expired, revoked, or minted for another app. Comes with WWW-Authenticate: Bearer error="invalid_token". The SDK refreshes once.
403insufficient_scopeThe token was not minted for this route.
403verifications_exhaustedThe token gave its maxVerifications verdicts. Your backend decides whether to mint another.
403presence_starts_exhausted10 presence starts on this token.
429rate_limited30 requests per minute per token, 1200 per minute per organization. Honour Retry-After.

Backend: get the verdict

Webhook

Set one endpoint per organization. The answer carries the signing secret, shown once; every PUT rotates it.

curl -X PUT https://api.verifiables.com/v1/sdk/webhook \
  -H "Authorization: Bearer oat_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/verifiables/sdk" }'

The URL must be public https. GET /v1/sdk/webhook shows it, DELETE /v1/sdk/webhook removes it.

The same endpoint receives twoddoc.job.completed when a queued 2D-DOC scan or comparison ends (asynchronous processing). Branch on the type field, or on the X-Webhook-Event header.

Each request that gave a verdict under a token is posted once:

{
  "id": "1f0c6c1e-6a6b-4f9e-9d7a-2b8e1d7c4a10",
  "type": "sdk.verification.completed",
  "timestamp": "2026-10-22T14:24:51.120Z",
  "data": {
    "tokenId": "sdkt_01JAQ4X7Y3M9V2K8T5R6N0P1QZ",
    "subject": "onboarding-77421",
    "mode": "live",
    "requestId": "Qy1x...",
    "kind": "nfc_identity",
    "verdict": "valid",
    "verdicts": [{ "outcome": "verdict_pass", "status": "valid", "credentialType": "icao:id_card" }],
    "verificationsUsed": 1,
    "maxVerifications": 3,
    "resultAvailableUntil": "2026-10-22T14:39:51.120Z"
  }
}

The webhook carries no value read from the document: it lands on an endpoint whose logs are not ours. Read the holder's data with GET /v1/sdk/tokens/:id, below.

HeaderValue
X-Webhook-IdDelivery id, identical on every retry: use it as your idempotency key
X-Webhook-Eventsdk.verification.completed
X-Webhook-TimestampISO 8601, when this attempt was sent
X-Webhook-Signaturev1=<hex>, the HMAC-SHA256 of <X-Webhook-Timestamp>.<raw body> with your secret
X-Webhook-Attempt1 to 5
User-AgentVerifiables-SDK/1.0

Answer with a 2xx within 10 seconds. A failed delivery is retried after 1, 5, 30 and 120 minutes. Redirects are not followed.

import { createHmac, timingSafeEqual } from 'node:crypto'

const MAX_SKEW_MS = 5 * 60 * 1000

function verifySdkWebhook(secret: string, rawBody: Buffer, headers: Record<string, string>): boolean {
  const timestamp = headers['x-webhook-timestamp']
  const signature = headers['x-webhook-signature']
  if (!timestamp || !signature?.startsWith('v1=')) return false
  const age = Date.now() - Date.parse(timestamp)
  if (!Number.isFinite(age) || Math.abs(age) > MAX_SKEW_MS) return false
  const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody.toString()}`).digest('hex')
  const presented = Buffer.from(signature.slice(3), 'hex')
  const computed = Buffer.from(expected, 'hex')
  return presented.length === computed.length && timingSafeEqual(presented, computed)
}

GET /v1/sdk/tokens/:id

Secret key of the same organization. Returns the session and, for 15 minutes after each verdict, the answer the app received, as we sent it: read the claims here, never from the app. ?consume=true deletes the results after this read.

{
  "id": "sdkt_01JAQ4X7Y3M9V2K8T5R6N0P1QZ",
  "status": "active",
  "subject": "onboarding-77421",
  "mode": "live",
  "verificationsUsed": 1,
  "maxVerifications": 3,
  "expiresAt": "2026-10-22T14:32:10.000Z",
  "resultsRetentionSeconds": 900,
  "results": [
    {
      "requestId": "Qy1x...",
      "kind": "nfc_identity",
      "at": "2026-10-22T14:24:51.120Z",
      "verdict": "valid",
      "verdicts": [{ "outcome": "verdict_pass", "status": "valid", "credentialType": "icao:id_card" }],
      "response": {
        "verdict": "valid",
        "presence": "proven",
        "documentType": "id_card",
        "issuingState": "FRA",
        "claims": { "familyName": "...", "givenNames": "...", "birthDate": "..." },
        "checks": [{ "id": "sod.signature", "status": "pass" }]
      }
    }
  ]
}

status is active, exhausted (cap reached), expired or revoked.

Revocation

  • DELETE /v1/sdk/tokens/:id: that token is refused at its next request.
  • POST /v1/sdk/tokens/revoke-all: every token your organization minted until now is refused. The dashboard has the same button.
  • Deleting an API key revokes every token it minted.

Security notes

  • A stolen token can spend at most maxVerifications verdicts, for at most its lifetime, on the routes of its scopes. It reads nothing stored.
  • Limit how many tokens your backend mints per user: we bound each token, you bound how many a user gets.
  • Keep requirePresence on for NFC: without it, chip files copied from someone else's card pass passive authentication.
  • Opt-in public key pinning is available in both SDKs. Pin at least two keys so a certificate rotation does not lock your app out.