Overview
Your server talks to FotoID's API with a requester API key. The user's phone talks to FotoID with its own device credentials. The identity data travels from the phone to you encrypted end to end.
Your app / site FotoID API User's iPhone
────────────── ────────── ─────────────
POST /api/VerificationSession ─► create session ─── APNs push ────► "Acme requests verification"
◄── sessionId, challenge, (payload not stored) …or the user scans your QR
qrCodeSvg, pushStatus
◄── GET /{id}/request ──────────── loads your request
(Face ID · live selfie · user picks fields)
◄── POST /{id}/response ─────────── sealed with YOUR public key
GET /api/VerificationSession/{id} ─► status + encrypted response (poll every 2–3 s)
◄── or a signed POST to your callbackUrl
Decrypt with your private key → check the challenge → DELETE /{id}FotoID can't read results. The phone encrypts each answer with your RSA public key. While it is stored, FotoID wraps it again under a server key, then deletes it once you collect it, or 10 minutes after the answer at the latest.
Quickstart
Base URL: https://api.fotoid.me. Every response uses the same envelope:
{
"hasErrors": false,
"content": { },
"apiErrors": [ { "errorCode": "…", "errorText": "…" } ]
}Before you start: FotoID onboards your organization as a requester and issues an API key (fid_live_…, sent as Authorization: Bearer). It also issues a webhook signing secret if you want callbacks. The key is shown once, so store it in a secrets manager. Request access →
1 · Generate your key pair
You own the keys. FotoID never sees your private key. Use RSA with 2048 bits or more, and send the public key as PEM (-----BEGIN PUBLIC KEY-----) with each session.
# Private key: keep it in your KMS / Key Vault / secrets store
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out fotoid-private.pem
# Public key: send this in encryptionKeys.publicKey
openssl rsa -in fotoid-private.pem -pubout -out fotoid-public.pemGive each key pair a keyId, such as key-2026-11. The phone echoes it in the encrypted payload, so you can rotate keys without breaking sessions that are still in flight.
2 · Create a session
Call this from your server, never from the browser, because it uses your API key. Save sessionId and challenge in the user's server-side session.
curl https://api.fotoid.me/api/VerificationSession \
-H "Authorization: Bearer $FOTOID_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"requiredFields": ["firstName", "lastName", "dateOfBirth"],
"livePhotoRequired": true,
"encryptionKeys": {
"publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBg...\n-----END PUBLIC KEY-----",
"keyId": "key-2026-11",
"algorithm": "RSA-2048"
},
"callbackUrl": "https://you.example/webhooks/fotoid",
"expiresInSeconds": 300
}'Response
{
"hasErrors": false,
"content": {
"sessionId": "3f6c1e2a-8b0d-4c9e-a1f7-5d2b9e4c7a10",
"status": "Pending",
"expiresOn": "2026-11-11T15:05:00Z",
"challenge": "7f9c2e4a8b1d6c3f9a0e5d7b2c4f8a1e",
"qrCodeData": "https://api.fotoid.me/v/3f6c1e2a8b0d4c9ea1f75d2b9e4c7a10",
"qrCodeSvg": "<svg …>…</svg>",
"pushStatus": "sent"
},
"apiErrors": []
}- With
email: the user's phones get a push, and only the account that owns that verified email can answer. - Without
email: the session is QR-only, and anyone with FotoID can answer it. pushStatusissent,not_sentornot_requested.not_sentdeliberately doesn't say why (no account, no device, or a push failure), so it can't be used to probe whether an email is registered.- Always show the QR (
qrCodeSvg, or renderqrCodeDatayourself) as the fallback, even when the push was sent. - If you omit
challenge, FotoID generates one and returns it. If you supply your own, it must be random and unique per session.
3 · Wait for the answer
Choose polling, webhooks, or both. Polling is the simplest option and works behind firewalls and on localhost.
// Poll every 2–3 seconds from your server (or proxy the status to your front end).
const r = await fetch(`https://api.fotoid.me/api/VerificationSession/${id}`, {
headers: { Authorization: `Bearer ${process.env.FOTOID_API_KEY}` }
});
const { content } = await r.json();
// content.status: Pending | Opened | Completed | Declined | Cancelled | Expired
if (content.status === 'Completed') {
const result = decryptFotoID(content.response, PRIVATE_KEY_PEM); // step 4
}| Status | Meaning | Final? |
|---|---|---|
Pending | Created. Waiting for the push to be opened or the QR to be scanned. | No |
Opened | The phone has loaded your request and the user is reviewing it. | No |
Completed | The user approved. response holds the encrypted payload, and emailMatched is set. | Yes |
Declined | The user declined. No data was shared. | Yes |
Cancelled | You called DELETE before an answer arrived. | Yes |
Expired | expiresInSeconds (60–900, default 300) passed with no answer. | Yes |
Webhooks
If you pass callbackUrl, FotoID POSTs the final state there. It must be a public HTTPS URL. The body matches the status response: { sessionId, status, completedOn, emailMatched, response }.
Each callback is signed with your webhook secret:
X-FotoID-Signature: t=1762873500,v1=5f2b…c9a1
│ └─ hex HMAC-SHA256(webhookSecret, "<t>.<raw body>")
└─ unix seconds when it was signedimport crypto from 'node:crypto';
// rawBody must be the exact bytes received. Verify before JSON.parse.
export function verifyFotoIDSignature(rawBody, header, secret) {
const parts = Object.fromEntries((header || '').split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > 300) return false; // stale or replayed
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1, 'hex');
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}Callbacks are delivered at least once, so de-duplicate by sessionId. Return 2xx quickly and do the decryption in the background if it's slow.
4 · Decrypt & validate
The response object is a hybrid-encrypted envelope. The AES-256 key is wrapped with RSA-OAEP using SHA-256 (including MGF1), and the identity JSON is sealed with AES-256-GCM using a 12-byte IV and a 16-byte tag.
{
"keyId": "key-2026-11",
"algorithm": "RSA-2048",
"encryptedKey": "base64 RSA-OAEP(AES key)",
"iv": "base64 12 bytes",
"ciphertext": "base64 AES-256-GCM ciphertext",
"tag": "base64 16-byte GCM tag"
}import crypto from 'node:crypto';
export function decryptFotoID(p, privateKeyPem) {
const aesKey = crypto.privateDecrypt(
{ key: privateKeyPem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
Buffer.from(p.encryptedKey, 'base64'));
const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, Buffer.from(p.iv, 'base64'));
decipher.setAuthTag(Buffer.from(p.tag, 'base64'));
const plain = Buffer.concat([decipher.update(Buffer.from(p.ciphertext, 'base64')), decipher.final()]);
return JSON.parse(plain.toString('utf8'));
}
const result = decryptFotoID(content.response, PRIVATE_KEY_PEM);
if (result.challenge !== req.session.fotoid.challenge) throw new Error('Challenge mismatch');
// Trust result.approvedFields only after this check.Then decide what the result means for your policy. For example, require livePhotoVerified: true and identityLevel: "idVerified" for account opening, and accept a self-certified identity for a newsletter.
5 · Clean up
Once you have the result, delete the session. This removes the stored ciphertext immediately. The same call cancels a session that is still pending.
curl -X DELETE https://api.fotoid.me/api/VerificationSession/$SESSION_ID \
-H "Authorization: Bearer $FOTOID_API_KEY"If you don't delete it, the answer is wiped 10 minutes after it arrives, and the session record is deleted after 24 hours.
API reference
Requester endpoints
| Method | Path | Purpose |
|---|---|---|
| POST | /api/VerificationSession | Start a verification (push + QR) |
| GET | /api/VerificationSession/{id} | Status, emailMatched and the encrypted response |
| DELETE | /api/VerificationSession/{id} | Cancel a pending session, or purge a completed result |
All requester endpoints require Authorization: Bearer fid_live_…. Requests are rate-limited per API key and per target email.
Create-session body
| Field | Type | Notes |
|---|---|---|
requiredFields | string[] | Required. The keys you want, in display order. See field keys. |
encryptionKeys | object | Required. { publicKey (PEM, RSA ≥ 2048), keyId, algorithm: "RSA-2048" } |
livePhotoRequired | bool | When true, the user takes a live selfie that is matched on-device to their license photo. |
email | string | Optional. Sends a push and restricts answers to the account that owns this verified email. |
challenge | string ≤ 128 | Optional anti-replay nonce. Generated if omitted. |
callbackUrl | https URL | Optional. Receives the signed webhook. |
returnUrl | URL / deep link | Optional. The phone opens it after answering, for mobile apps. |
expiresInSeconds | int | 60–900. Default 300. |
Field keys
Ask for the minimum you need. Users see every requested field and can untick any of them. Fields the user doesn't approve are left out of approvedFields.
| Key | Value |
|---|---|
emailAddress | The user's verified FotoID email |
firstName · lastName | Name as on the license (OCR-checked at registration) |
dateOfBirth | MM/DD/YYYY |
street1 · street2 · street3 | Address lines |
city · state · zipCode · country | Address |
cellPhone | Cell phone number |
licenseNumber | Driver's license number |
currentLocation | Live reverse-geocoded location at the time of approval |
Decrypted payload
{
"requestId": "3f6c1e2a-8b0d-4c9e-a1f7-5d2b9e4c7a10",
"challenge": "7f9c2e4a8b1d6c3f9a0e5d7b2c4f8a1e",
"livePhotoVerified": true,
"matchConfidence": "High",
"identityLevel": "idVerified",
"verifiedDocumentTypes": ["driversLicense"],
"approvedFields": {
"firstName": "Alex",
"lastName": "Morgan",
"dateOfBirth": "04/15/1991"
},
"verifiedAt": "2026-11-11T15:01:22Z",
"appVersion": "1.0"
}| Field | Meaning |
|---|---|
challenge | Copied verbatim from your request. Reject the response if it doesn't match. |
livePhotoVerified | true only if a live selfie was requested and matched the license photo |
matchConfidence | "Very High" · "High" · "Medium", or null when no live photo was taken |
identityLevel | "idVerified" (checked against a government ID) or "selfCertified" |
approvedFields | Only the fields the user approved, keyed by the names you requested |
emailMatched (on the session) | true when an email-targeted session was answered by that email's account |
Mobile apps
For a native app, pass a returnUrl such as yourapp://fotoid/done. After the user answers, the FotoID app opens it to send them back to your app. Keep collecting the result through your server by polling or webhook, so your private key never ships in the app binary.
QR codes encode a short link (https://api.fotoid.me/v/{id}). Scanned inside the FotoID app, the link opens your request. Scanned with the iPhone camera, it shows a landing page that explains how to continue in the app.
Security checklist
- Call the API from your server only. Never put your API key or private key in a browser or mobile app.
- Store the session's
challengeserver-side, and compare it after decryption before trusting any field. - Verify
X-FotoID-Signatureagainst the raw body with a constant-time compare, and reject timestamps older than 5 minutes. - De-duplicate webhooks by
sessionId. - Check
emailMatchedwhen you targeted an email, andlivePhotoVerified/identityLevelagainst your policy. - Request the fewest fields you can. Data you don't receive is data you don't have to protect.
- Call
DELETEonce you have the result. - Rotate keys by introducing a new
keyId. Keep the old private key until in-flight sessions have expired.
Ready to integrate?
We'll set up your organization as a requester and send your API key.