Constaia
Concepts

Face verification

Optional module: a selfie with active and passive liveness compared 1:1 with the photo on the ID document. Open models on our EU servers, with no images or face templates stored. EEA accounts only.

Face verification checks that the person sending an ID document is its holder: they take a selfie with two gestures (liveness check) and we compare it 1:1 with the photo on their Spanish ID card, NIE/TIE, passport or driving licence. It is an optional module, off by default.

This is biometrics (GDPR Article 9)

It is only offered to accounts in the European Economic Area (billing country in the EU, Iceland, Liechtenstein or Norway). It is not available in the United States or to USD accounts or accounts with a US country (Illinois BIPA, Texas CUBI and other state laws). The account owner turns it on by accepting the biometric data processing addendum, which is recorded in the audit log.

What we keep (and what we don't)

DataStored?
Selfie framesNo. Processed in memory and deleted as soon as the comparison finishes (seconds).
Face template or vectorNo. Never stored or returned.
Document photoNot stored for this. We only use the original if your storage already keeps it; otherwise the page (or your integration) sends the same file again, which we check by its SHA-256 fingerprint.
Result (face_match)Yes: status, similarity, liveness score, gestures and reasons. It follows the results retention (account or template) and is emptied, together with the consent, by DELETE /v1/analyses?metadata[...] (also on links and dossiers with that metadata), when the link is cancelled or when the dossier is deleted (results_deleted_at).
ConsentYes: date, IP, browser, version and fingerprint of the text shown.

We do not do 1:N identification (searching for someone among other people) or use faces to train models.

Price

1 credit per selfie compared in live mode (FACE_VERIFICATION_CREDITS); free in test mode. It is not charged if the person skips the selfie (no camera or no consent), has to retake it for quality (no face, low light, blurry image) or if it fails on our side. Each attempt that reaches the comparison counts (up to 3 per link).

Models

All are open, licensed for commercial use, and run on CPU on our EU servers (no third parties):

StepModelLicence
Face detection and 5 landmarksYuNet (OpenCV Zoo)MIT
Comparison (128-dimension vector, cosine)SFace (OpenCV Zoo)Apache-2.0
Passive liveness (printed photo, screen, mask)MiniFASNet V2 + V1SE (Silent-Face-Anti-Spoofing)Apache-2.0
Gesture guidance in the browserMediaPipe Face Landmarker (served from our domain)Apache-2.0

Server time: under 1.5 s per verification (about 150 ms with 6 frames in our tests).

Add face_verification when creating the link (or the template):

{
  "documents": [{ "key": "id", "label": "ID card", "expect": ["es_dni", "es_nie", "passport"] }],
  "face_verification": { "enabled": true, "required": true }
}
  1. After the documents, the page shows the Selfie step with the explicit consent text (purpose, legal basis: consent, immediate deletion, controller and processor).
  2. The person opens the front camera and makes 2 random gestures chosen by the server: turning their head to one side, moving closer or blinking. The browser only guides; the server measures them again on the frames.
  3. If they have no camera or do not want to, they can continue without the selfie: the result is review (no_camera or declined) and you review it.
  4. Up to 3 attempts. Quality failures (no face, low light, blurry image) do not use an attempt.

The link is not completed until the selfie step is closed. required: true keeps the dossier incomplete while it is missing, review if it is left for review and invalid if it does not match. The verification_link.completed webhook and the link GET include face_verification (step and attempts) and face_match:

{
  "status": "match",
  "similarity": 0.61,
  "liveness": {
    "status": "passed",
    "score": 0.93,
    "challenges": [{ "type": "turn_left", "passed": true }, { "type": "blink", "passed": true }]
  },
  "quality": { "frames": 6, "face_size_px": 88, "sharpness": 142.3, "brightness": 131, "document_face_size_px": 74 },
  "reasons": [],
  "thresholds": { "match": 0.42, "review_band": 0.12, "liveness": 0.6 },
  "document": { "analysis_id": "an_…", "document_key": "id" },
  "checked_at": "2026-09-30T10:12:03Z"
}

The person never sees the similarity or the scores (with show_result they only see the status).

How we decide

  • match: similarity ≥ threshold and a passed liveness check. There is never a match without a passed liveness check.
  • no_match: similarity below threshold − band (different_person).
  • review: everything else: similarity inside the band (similarity_borderline), unconfirmed liveness, no face on the document, no camera…

Liveness is passed if both gestures are seen in the frames and the passive score is above its threshold; failed if a gesture is missing, there is more than one face, the face changes during the capture or the landmarks sent by the browser do not match the images.

Variable (server)DefaultMeaning
FACE_MATCH_THRESHOLD0.42Cosine similarity from which there is a match. OpenCV recommends 0.363 for SFace on LFW (same kind of photos); we raise it because document vs selfie is harder and we prefer few false positives.
FACE_REVIEW_BAND0.12Band below the threshold that goes to review (0.30–0.42).
LIVENESS_THRESHOLD0.6Minimum MiniFASNet score.

Thresholds are calibrated with pnpm --filter @constaia/api face:eval on licensed, consented pairs (document, selfie, same person yes/no): the harness computes FAR/FRR, EER and the threshold for FAR ≤ 0.1%.

With your own capture: POST /v1/face-verifications

If you capture the selfie in your app, send the frames and the analysis_id of the document (a completed analysis of a photo ID). It only works with the module turned on; you collect the explicit consent.

# 1. Random challenges (recommended): single-use session, 10 minutes
curl -X POST https://api.constaia.com/v1/face-verifications/sessions \
  -H "Authorization: Bearer $CONSTAIA_API_KEY"
# → { "session_id": "fvs_…", "challenges": ["turn_left", "blink"], "expires_at": "…" }

# 2. 5–8 frames in order: facing the camera, the gestures, and facing the camera again
curl https://api.constaia.com/v1/face-verifications \
  -H "Authorization: Bearer $CONSTAIA_API_KEY" \
  -F analysis_id=an_01J9Z8Q3K4M5N6P7Q8R9S0T1V2 \
  -F frames=@f1.jpg -F frames=@f2.jpg -F frames=@f3.jpg -F frames=@f4.jpg -F frames=@f5.jpg -F frames=@f6.jpg \
  -F document=@id.jpg \
  -F 'meta={"session_id":"fvs_…","frames":[{"phase":"neutral","t":0},{"phase":"neutral","t":400},{"phase":"turn_left","t":1500},{"phase":"turn_left","t":1600},{"phase":"blink","t":2600},{"phase":"neutral","t":3400}]}'
FieldDescription
analysis_idAnalysis of the document (ID card, NIE/TIE, passport, licence). Other types → 422 face_document_not_identity.
frames5–8 JPEG/PNG/WEBP (≤ 2 MB, ~640 px), not mirrored.
metaJSON with session_id (or challenges without a session), and per frame phase (neutral, turn_left, turn_right, move_closer, blink), t (ms) and, optionally, landmarks (nose, left_eye, right_eye, normalised 0–1) and blink (0–1).
documentThe same document file if we no longer keep it (storage: "none" or review without review). Missing → 422 face_document_unavailable.
dossier_idOptional: the result counts in that dossier.

Returns 201 with { id: "fv_…", object: "face_verification", status, face_match, … }; GET /v1/face-verifications/{id} retrieves it. Without declared gestures there is no passed liveness check (no_active_challenge), so the best possible result is review.

Errors

CodeWhen
403 face_verification_not_enabledThe account has not turned the module on.
403 face_verification_unavailableAccount outside the EEA, in the US or in USD.
503 face_verification_disabledThe module is not available on the service right now.
422 face_retryThe face cannot be seen well (no face, dark, blurry): retry.
422 face_document_unavailable / face_document_mismatchThe document is missing or is not the same file.
409 face_session_invalidChallenge session expired or already used.
429 face_attempts_exhaustedNo attempts left on the link.

On this page