Integration guide

Integrate "Verify with FotoID.me"

One session gives you both a push to the user's phone and a QR fallback. The result is encrypted with your key on the phone, and FotoID relays it without being able to read it.

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.

Verification session flow
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.pem

Give 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.
  • pushStatus is sent, not_sent or not_requested. not_sent deliberately 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 render qrCodeData yourself) 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
}
StatusMeaningFinal?
PendingCreated. Waiting for the push to be opened or the QR to be scanned.No
OpenedThe phone has loaded your request and the user is reviewing it.No
CompletedThe user approved. response holds the encrypted payload, and emailMatched is set.Yes
DeclinedThe user declined. No data was shared.Yes
CancelledYou called DELETE before an answer arrived.Yes
ExpiredexpiresInSeconds (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 signed
import 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

MethodPathPurpose
POST/api/VerificationSessionStart 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

FieldTypeNotes
requiredFieldsstring[]Required. The keys you want, in display order. See field keys.
encryptionKeysobjectRequired. { publicKey (PEM, RSA ≥ 2048), keyId, algorithm: "RSA-2048" }
livePhotoRequiredboolWhen true, the user takes a live selfie that is matched on-device to their license photo.
emailstringOptional. Sends a push and restricts answers to the account that owns this verified email.
challengestring ≤ 128Optional anti-replay nonce. Generated if omitted.
callbackUrlhttps URLOptional. Receives the signed webhook.
returnUrlURL / deep linkOptional. The phone opens it after answering, for mobile apps.
expiresInSecondsint60–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.

KeyValue
emailAddressThe user's verified FotoID email
firstName · lastNameName as on the license (OCR-checked at registration)
dateOfBirthMM/DD/YYYY
street1 · street2 · street3Address lines
city · state · zipCode · countryAddress
cellPhoneCell phone number
licenseNumberDriver's license number
currentLocationLive 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"
}
FieldMeaning
challengeCopied verbatim from your request. Reject the response if it doesn't match.
livePhotoVerifiedtrue 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"
approvedFieldsOnly 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 challenge server-side, and compare it after decryption before trusting any field.
  • Verify X-FotoID-Signature against the raw body with a constant-time compare, and reject timestamps older than 5 minutes.
  • De-duplicate webhooks by sessionId.
  • Check emailMatched when you targeted an email, and livePhotoVerified / identityLevel against your policy.
  • Request the fewest fields you can. Data you don't receive is data you don't have to protect.
  • Call DELETE once 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.