The Sylmera Identity API
You create a link. Your customer opens it and photographs their document. You read the decision, or we push it to you. That is the whole surface.
Everything is JSON over HTTPS. There is no SDK, nothing to install and no
version header to pin. The base address is
https://sylmera.com.
Your first verification
Create an account in the console, generate a sandbox key, and run this. It works before anyone has approved your account.
curl https://sylmera.com/api/verify/sessions.php \ -H "X-Sylmera-Key: syl_test_votre_cle" \ -H "Content-Type: application/json" \ -d '{ "client_reference": "user-42", "checks": ["document", "face"], "return_url": "https://your-company.example/done" }'
<?php $ch = curl_init('https://sylmera.com/api/verify/sessions.php'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'X-Sylmera-Key: syl_test_votre_cle', 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'client_reference' => 'user-42', 'checks' => ['document', 'face'], 'return_url' => 'https://your-company.example/done', ]), ]); $reponse = json_decode(curl_exec($ch), true); curl_close($ch); // Envoyez cette adresse a votre client echo $reponse['session']['url'];
const r = await fetch('https://sylmera.com/api/verify/sessions.php', { method: 'POST', headers: { 'X-Sylmera-Key': 'syl_test_votre_cle', 'Content-Type': 'application/json' }, body: JSON.stringify({ client_reference: 'user-42', checks: ['document', 'face'], return_url: 'https://your-company.example/done' }) }); const { session } = await r.json(); // Send session.url to your customer
import requests r = requests.post( 'https://sylmera.com/api/verify/sessions.php', headers={'X-Sylmera-Key': 'syl_test_votre_cle'}, json={ 'client_reference': 'user-42', 'checks': ['document', 'face'], 'return_url': 'https://your-company.example/done', }, timeout=15, ) session = r.json()['session'] # Send session['url'] to your customer
You get back the link. Send it by email, by SMS, or open it in a window.
{
"success": true,
"session": {
"id": "3f9c1b7a-2e4d-4c58-9a11-7e0d5b83c642",
"url": "https://sylmera.com/v/9c2f8a1d…",
"kind": "person",
"checks": ["document", "face"],
"environment": "sandbox",
"status": "created",
"client_reference": "user-42",
"created_at": "2026-09-15 11:04:22",
"expires_at": "2026-09-22 11:04:22"
}
}
Call this from your server, never from a browser. The key must not travel inside a page — anyone who reads it can create verifications that you pay for.
API keys
Send the key in X-Sylmera-Key, or as
Authorization: Bearer … if that suits your HTTP client
better. Both are read.
| Prefix | Environment | What it does |
|---|---|---|
syl_test_ | sandbox | Works as soon as you create it. Nothing is charged and no real decision is ever made. |
syl_live_ | live | Works once your account is approved and has credit. Real decisions, real charges. |
A key is shown once, when it is created. We store only its SHA-256 fingerprint, so we cannot show it to you again and neither can anyone who steals our database. Lose it and you create another; revoking is instant.
Sandbox
Sandbox is the same code path as live: the same screens, the same engine, the same responses. The differences are that nothing is billed, and the decision is not treated as a real one by anything downstream.
Photograph any document you have to hand. If you want to see a rejection, photograph something expired, or a document belonging to someone under eighteen.
Create a session
/api/verify/sessions.php| Field | Type | Meaning |
|---|---|---|
client_reference | string | Your own identifier for this person — a user id, an order number. Up to 120 characters. It comes back on every read and in every webhook. Optional but strongly advised. |
checks | array | Any of document, face,
address, aml,
company. Defaults to
["document","face"]. A person check always
includes document: the face is compared with its photo. |
documents | array | The documents the person may use: passport,
id_card, driving_licence,
residence_permit, other. Defaults to
the first three. If the machine-readable zone shows another
type or country than the one the person chose, the session
reports what the document says; a type you do not accept is
rejected. |
capture | string | camera (guided camera, default),
upload (a photo or scan) or both.
The face check is always live. |
return_url | string | Where to send your customer once they finish. Must be
https. Optional. |
expires_in_minutes | integer | How long the link stays usable. Between 15 and 43200. Defaults to 10080, which is seven days. |
kind | string | person (default) or business. A
business check asks for the registration number and reads the
official register (United Kingdom today). |
locale | string | Language hint for the hosted page, such as en.
Optional. |
Responds 201 with the session, link included.
Read a session
/api/verify/sessions.php?id=<id>{
"success": true,
"session": {
"id": "3f9c1b7a-…",
"status": "approved",
"decision": "approved",
"client_reference": "user-42",
"completed_at": "2026-09-15 11:06:02",
"person": {
"name": "ANGELA ZOE SPECIMEN",
"date_of_birth": "1981-06-16",
"document_country": "GB"
}
}
}
The person block appears only on an approved session. On a
rejected or held session you get reasons instead — the
reasons a customer is allowed to read. Scores, fraud signals and the
photographs never leave through this endpoint; they live in your portal
and in our audit log.
List sessions
/api/verify/sessions.php
The 25 most recent, newest first, plus this month's usage. Narrow it with
status, client_reference,
environment or limit. This is for a dashboard,
not for polling — use a webhook for that.
Statuses and decisions
| Status | What it means |
|---|---|
created | Link made, not opened yet. |
open | Your customer has opened it. |
submitted | Photographs are in, the engine is working. |
approved | Passed. This is the one you act on. |
rejected | Failed on something conclusive. |
review | A person needs to look. Not a failure. |
expired | The link ran out before it was used. |
cancelled | Stopped from the portal. |
With the standard rules, only a few findings reject a person on their own: being under eighteen, a document edited in software, a document type you do not accept, and a proof of address re-saved in an editor. Everything else — including a face that does not match and a name close to a sanctions list — goes to a human, after the person has been asked to retake unclear photos. If a check cannot run at all, that too becomes a review, never a rejection. You can change this in your decision rules.
Decision rules
In the console, Settings → Decision rules lets an owner choose what
each kind of finding does: standard, send to review, reject,
or ignore. You can also set the risk score from which a case goes to a
person (30 by default), a score from which it is rejected outright (off by
default), and whether a case that would wait for a person is rejected
instead. The rules apply to every check that finishes after you save
them, whether it came from a link, the API or the console. Being under
eighteen and a sanctions match can be sent to review or rejected, never
ignored. A failure on our side is never turned into a rejection.
Manual review in the webhook
When someone on your team decides a case from the console, the
verification.completed event carries a review
block. The internal note written with the decision never leaves the
console.
"review": { "decided_by": "reviewer", "decision": "reject", "labels": [ { "code": "face_mismatch", "label": "Face does not match the document" } ] }
| Reason code | Meaning |
|---|---|
document_unreadable | Document unreadable or incomplete |
document_expired | Document expired |
document_not_accepted | Document type not accepted |
document_tampered | Document edited or forged |
face_mismatch | Face does not match the document |
not_live | Photo not taken live |
duplicate | Person already verified under another account |
sanctions_confirmed | Sanctions match confirmed |
underage | Under the minimum age |
data_mismatch | Details differ from what the person gave you |
other | Other reason (a note is required) |
These codes never change; new ones may be added.
Webhooks
Add an address in your portal, one per environment, and we post to it the
moment a verification finishes. Two events exist today:
verification.completed and
verification.submitted.
POST https://your-company.example/hooks/sylmera
X-Sylmera-Event: verification.completed
X-Sylmera-Delivery: 8d1c…
X-Sylmera-Signature: t=1789459562,v1=4f3a…
{
"id": "8d1c…",
"type": "verification.completed",
"created": 1789459562,
"environment": "live",
"data": {
"session": { /* the same object as a read */ }
}
}
Answer 2xx quickly and do your work afterwards. Anything else
is retried, with the gaps widening: one minute, five, fifteen, one hour,
three, six. After that the delivery is abandoned and stays visible in your
portal.
The same event can arrive twice — a timeout on your side that still
processed the payload looks identical to a failure from ours. Store
id and ignore one you have already handled.
In Test mode, the Webhooks screen of your portal can point your test address at a receiver we host. Every delivery it gets is listed there, with its headers, its body and whether the signature is valid — what your own server will receive, before you have one. It accepts test results only and keeps them for seven days. Never use a placeholder or a public request-bin as your address: an approved person arrives with the name and date of birth read on their document.
Verifying a signature
The header is t=<unix seconds>,v1=<hex>. The
signed value is the timestamp, a full stop, then the raw request body.
HMAC-SHA256, with your endpoint secret as the key.
<?php $corps = file_get_contents('php://input'); $entete = $_SERVER['HTTP_X_SYLMERA_SIGNATURE'] ?? ''; parse_str(strtr($entete, ',', '&'), $p); $t = (int) ($p['t'] ?? 0); $v1 = (string) ($p['v1'] ?? ''); // Un message rejoue la semaine prochaine ne doit pas passer if (abs(time() - $t) > 300) { http_response_code(400); exit; } $attendu = hash_hmac('sha256', $t . '.' . $corps, VOTRE_SECRET); // hash_equals : la comparaison ne doit pas fuir par sa duree if (!hash_equals($attendu, $v1)) { http_response_code(400); exit; } http_response_code(200);
const crypto = require('crypto'); function valide(corpsBrut, entete, secret) { const p = Object.fromEntries( entete.split(',').map(x => x.split('=')) ); const t = parseInt(p.t, 10); // Rejeu : au-dela de cinq minutes, on refuse if (Math.abs(Date.now() / 1000 - t) > 300) return false; const attendu = crypto .createHmac('sha256', secret) .update(t + '.' + corpsBrut) .digest('hex'); return crypto.timingSafeEqual( Buffer.from(attendu), Buffer.from(p.v1) ); }
import hashlib, hmac, time def valide(corps_brut: bytes, entete: str, secret: str) -> bool: p = dict(x.split('=', 1) for x in entete.split(',')) t = int(p['t']) # Rejeu : au-dela de cinq minutes, on refuse if abs(time.time() - t) > 300: return False attendu = hmac.new( secret.encode(), f'{t}.'.encode() + corps_brut, hashlib.sha256, ).hexdigest() return hmac.compare_digest(attendu, p['v1'])
Read the body before any framework parses it. A body that has been decoded and re-encoded is no longer byte-for-byte what we signed, and every signature will fail for reasons that look like a bug in our code.
Errors
Every failure has the same shape: success: false, a stable
error string, and a message written for a human
reading a log. Branch on error, never on the message.
| Code | HTTP | What to do |
|---|---|---|
UNAUTHORIZED | 401 | The key is missing, revoked or wrong. Not retryable. |
ACCOUNT_PENDING | 403 | Live key on an account nobody has approved yet. Your sandbox key still works. |
ACCOUNT_SUSPENDED | 403 | Talk to us. |
INSUFFICIENT_CREDIT | 402 | Out of credit. Top up in the portal. Sandbox is unaffected. |
NOT_FOUND | 404 | No session with that id belongs to you. |
VALIDATION_ERROR | 422 | A field is wrong; the message says which. Fix it and resend. |
RATE_LIMITED | 429 | Slow down, then retry. |
STORAGE_ERROR | 500 | Ours. Safe to retry. |
Rate limits
120 requests a minute per key, which is far more than creating links needs. If you are anywhere near it you are probably polling — take a webhook instead and the problem disappears.
Going live
- Have your account approved in the console, then create a live key.
- Top up your credit. A live session is refused with
INSUFFICIENT_CREDITwhen the balance will not cover it; sandbox keeps working either way. - Register a live webhook address, separate from your sandbox one, and check a delivery arrives.
- Decide what your product does with
reviewbefore a customer hits it. It is the status people forget, and the one that strands real customers. - Ask us for the data processing agreement. Selling this service makes us a processor of your customers' documents, and a serious counterparty will ask you for that paperwork before they sign.
Something here wrong, missing or ambiguous? Tell us and it gets fixed — the documentation is part of the product.