# FaceCard documentation
> FaceCard adds face sign-in to your app. A browser SDK computes a face embedding (128 numbers) on the user's device, the API matches it against the users enrolled in your app only, and your server verifies a signed ES256 user token. FaceCard stores embeddings only, never face images. This documentation covers the quickstarts, concepts, the v1 REST API at https://api.facecard.dev, error codes, plans and limits, security and privacy, and the @facecard/sdk 4.0 package.
---
# Introduction
Source: https://docs.facecard.dev/docs
FaceCard is face sign-in for your app. A user enrolls once with their email and a short camera capture. After that, they sign in by looking at the camera. No password is involved.
## What FaceCard stores
FaceCard stores **embeddings only**. The browser SDK runs a face model on the user's device and turns the camera frames into a list of 128 numbers (a descriptor). Only those numbers leave the browser. No face image or video is uploaded, and none is stored anywhere. See [Security and privacy](https://docs.facecard.dev/docs/security-and-privacy) for the full data and retention schedule.
## How it works
1. Your page loads the browser SDK with a **publishable key**. The key only works from the origins you allow.
2. For every enroll or sign-in, the SDK asks the API for a single-use **challenge**, captures frames, and computes descriptors in the browser.
3. The API compares the descriptors with the templates enrolled in **your app only** and returns a **user token** (a signed JWT that lasts one hour) when they match.
4. Your server verifies the token, either online with `POST /v1/tokens/verify` or offline with the public keys at `/.well-known/jwks.json`, and starts its own session.
```text
Browser (publishable key) api.facecard.dev Your server (secret key)
challenge, descriptors ---> match inside your app
<--- user token (ES256 JWT)
send token to your server ---------------------------> verify token, create session
```
## Honest limits
- Liveness checks run in the browser and are **advisory**. They raise the bar for casual spoofing but they are not a guarantee against a determined attacker. Read [Liveness is advisory](https://docs.facecard.dev/docs/concepts/liveness) before you decide what FaceCard should protect.
- Matching uses a distance threshold and a margin between the best and second best candidate. A face that is not clear enough returns `no_match`, and the user can try again.
- FaceCard identifies a user inside one app. Users are never shared across apps.
## Where to go next
| I want to | Read |
|---|---|
| Add sign-in to a web page | [Quickstart: browser](https://docs.facecard.dev/docs/quickstart-browser) |
| Verify a user on my server | [Quickstart: server](https://docs.facecard.dev/docs/quickstart-server) |
| Understand keys, tokens and matching | [Concepts](https://docs.facecard.dev/docs/concepts) |
| Call the REST API directly | [API reference](https://docs.facecard.dev/docs/api-reference) |
| See plan limits | [Plans and limits](https://docs.facecard.dev/docs/plans-and-limits) |
## Hosts
| Host | Purpose |
|---|---|
| `facecard.dev` | The FaceCard consumer app |
| `api.facecard.dev` | The API (`https://api.facecard.dev/v1/...`) |
| `developers.facecard.dev` | The developer portal: create apps and keys, see usage |
| `docs.facecard.dev` | This site |
The machine readable API description is at `https://api.facecard.dev/openapi.json`. This site also publishes [llms.txt](https://docs.facecard.dev/llms.txt) and [llms-full.txt](https://docs.facecard.dev/llms-full.txt) for AI tools.
---
# Quickstart: browser
Source: https://docs.facecard.dev/docs/quickstart-browser
You need a FaceCard app and a publishable key. Create both in the [developer portal](https://developers.facecard.dev):
1. Create an app and add your site's origin to its allow list, for example `https://app.example.com`. For local work add `http://localhost:3000` (scheme, host and port must match exactly).
2. Create a **publishable key**. It looks like `fc_pk_live_xxx`. It is safe to put in browser code, but it only works from the origins you allowed.
The camera needs a secure context, so serve your page over `https://` (or use `localhost`).
## Script tag
```html
```
If you use a bundler, install the package instead and import it the same way:
```bash
npm install @facecard/sdk
```
## React
```bash
npm install @facecard/sdk
```
```tsx
import { FaceCardProvider, useFaceCard } from '@facecard/sdk/react';
function SignInButton() {
const fc = useFaceCard();
async function onClick() {
const { token } = await fc.signIn();
await fetch('/api/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
}
return ;
}
export default function App() {
return (
);
}
```
Enroll the same way with `fc.enroll({ email, name, consent })`.
## What to do with the token
`signIn()` and `enroll()` return a user token. Treat it as proof that FaceCard matched a face in your app. Send it to your own server and verify it there ([Quickstart: server](https://docs.facecard.dev/docs/quickstart-server)). Do not trust a token that was not verified server side.
## Consent
Enrollment fails with `consent_required` unless the user accepted your privacy notice. Show the notice before you call `enroll`, and pass `consent: { policy_version, accepted: true }` only after the user agrees, where `policy_version` names the notice they saw. FaceCard records the policy version, time, IP address and user agent. See [Security and privacy](https://docs.facecard.dev/docs/security-and-privacy#consent).
## Common errors
| Error code | What it means |
|---|---|
| `origin_not_allowed` | The page origin is not on the app's allow list. |
| `no_match` | No enrolled face matched clearly enough. Let the user retry. |
| `email_taken` | That email is already enrolled in this app. |
| `face_already_enrolled` | This face is already enrolled in this app. |
| `rate_limited` | Too many attempts from this IP. Wait for `Retry-After`. |
The full list is in [Errors](https://docs.facecard.dev/docs/errors).
---
# Quickstart: server
Source: https://docs.facecard.dev/docs/quickstart-server
Your server uses a **secret key** (`fc_sk_live_xxx`). Create one in the [developer portal](https://developers.facecard.dev). The key is shown once, so store it in your secret manager or environment, never in browser code or in git.
```bash
npm install @facecard/sdk
export FACECARD_SECRET_KEY=fc_sk_live_xxx
```
## Verify a token from the browser
```ts
import { createFaceCardServer, FaceCardError } from '@facecard/sdk';
const facecard = createFaceCardServer({ secretKey: process.env.FACECARD_SECRET_KEY! });
export async function startSession(token: string) {
const result = await facecard.tokens.verify(token);
if (!result.valid) {
throw new Error(`Token rejected: ${result.reason}`);
}
// claims: sub (user id), app_id, email, iat, exp, jti
return result.claims;
}
```
Verifying with `tokens.verify` calls the API. If you want to avoid a network call, verify the signature locally against the public keys:
```ts
const claims = await facecard.verifyTokenOffline(token);
console.log(claims.sub, claims.email);
```
`verifyTokenOffline` checks the ES256 signature, the issuer, the audience and the expiry using the JWKS at `https://api.facecard.dev/.well-known/jwks.json`. See [User tokens and JWKS](https://docs.facecard.dev/docs/concepts/user-tokens).
## List users
```ts
const page = await facecard.users.list({ limit: 50 });
for (const user of page.users) {
console.log(user.id, user.email);
}
if (page.next_cursor) {
const next = await facecard.users.list({ limit: 50, cursor: page.next_cursor });
}
```
`users.list` returns only users of the app that owns the key, and never returns face data.
## Read usage and delete a user
```ts
const usage = await facecard.usage('2026-10');
console.log(usage.auths, 'of', usage.limit);
await facecard.users.delete('usr_123'); // removes the user and all their face templates
```
## The same thing with curl
```bash
curl https://api.facecard.dev/v1/users?limit=50 \
-H "Authorization: Bearer fc_sk_live_xxx"
curl -X POST https://api.facecard.dev/v1/tokens/verify \
-H "Authorization: Bearer fc_sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{"token":""}'
```
## Handling errors
Every failed call throws a `FaceCardError` with a `code` and an HTTP `status`:
```ts
try {
await facecard.users.get('usr_missing');
} catch (err) {
if (err instanceof FaceCardError && err.code === 'not_found') {
// handle it
} else {
throw err;
}
}
```
See [Errors](https://docs.facecard.dev/docs/errors) and the [server SDK reference](https://docs.facecard.dev/docs/sdk/server).
---
# Concepts
Source: https://docs.facecard.dev/docs/concepts
Read these pages once and the rest of the docs will make sense.
| Page | What it covers |
|---|---|
| [Apps and keys](https://docs.facecard.dev/docs/concepts/apps-and-keys) | An app is your isolation boundary. Secret keys, publishable keys and the Origin allow list. |
| [User tokens and JWKS](https://docs.facecard.dev/docs/concepts/user-tokens) | The signed token a successful sign-in returns, and how to verify it. |
| [Challenges and replay protection](https://docs.facecard.dev/docs/concepts/challenges) | Why every enroll and match needs a fresh single-use challenge. |
| [Matching: threshold and margin](https://docs.facecard.dev/docs/concepts/matching) | How a face is compared and when the answer is `no_match`. |
| [Liveness is advisory](https://docs.facecard.dev/docs/concepts/liveness) | What the browser liveness check does and does not protect against. |
---
# Apps and keys
Source: https://docs.facecard.dev/docs/concepts/apps-and-keys
## Apps
An **app** is the unit of isolation in FaceCard. Users, face templates, keys and usage all belong to exactly one app. A face enrolled in one app is never matched against another app, and an API call can only see the app that owns its credential.
You create apps in the [developer portal](https://developers.facecard.dev). Each app has a name and a list of allowed origins. The app is always **derived from the credential** on the request. If a request body contains an `app_id`, it is ignored.
How many apps and keys you can create depends on your plan. See [Plans and limits](https://docs.facecard.dev/docs/plans-and-limits).
## Two kinds of keys
| | Secret key | Publishable key |
|---|---|---|
| Format | `fc_sk_live_` + 32 characters | `fc_pk_live_` + 24 characters |
| Where | Your server only | Browser code |
| Header | `Authorization: Bearer fc_sk_live_xxx` | `X-FaceCard-Key: fc_pk_live_xxx` |
| Can call | Users, token verification, usage | Only `POST /v1/challenges`, `POST /v1/enroll`, `POST /v1/match` |
| Origin rule | A browser Origin that is not on the allow list is rejected | The request Origin must be on the allow list |
Keys are stored as SHA-256 hashes, with a prefix and the last four characters kept for display. The full key is shown **once**, when you create it. If you lose it, create a new key and revoke the old one.
## The Origin allow list
A publishable key is public by design: anyone can read it in your page source. What stops another site from using it is the **Origin allow list**. For a publishable key, the API requires the request `Origin` header to exactly match one of the app's `allowed_origins`:
- The match covers scheme, host and port. `https://app.example.com` does not match `https://app.example.com:8443` or `http://app.example.com`.
- There are no wildcards.
- Add `http://localhost:3000` (or your dev port) while you develop, and remove it before launch if you do not want it.
A request from an origin that is not listed fails with `403 origin_not_allowed`.
The allow list also applies to secret keys when a browser Origin is present: a secret key used from an unlisted browser origin is rejected. Secret keys belong on servers, which send no Origin header.
## Keeping keys safe
- Never put a secret key in client code, a mobile app bundle or a public repository.
- Rotate by creating a new key, deploying it, then revoking the old one in the portal.
- Rate limits are per credential: 600 requests a minute per secret key, and 30 requests a minute per IP address on the challenge, enroll and match routes. See [Plans and limits](https://docs.facecard.dev/docs/plans-and-limits#rate-limits).
---
# User tokens and JWKS
Source: https://docs.facecard.dev/docs/concepts/user-tokens
Every successful `POST /v1/enroll` and `POST /v1/match` returns a **user token** and its expiry. The token is an ES256 JWT that lasts **one hour**.
## Claims
| Claim | Value |
|---|---|
| `iss` | `https://api.facecard.dev` |
| `aud` | `facecard` |
| `sub` | The FaceCard user id |
| `app_id` | The app the user belongs to |
| `email` | The user's email |
| `iat`, `exp` | Issued at and expiry, in seconds since the epoch |
| `jti` | A unique token id |
## What the token is for
The token proves FaceCard matched a face in your app. You use it in two ways:
1. **Send it to your server** and verify it, then start your own session. This is the normal flow.
2. **Call `/v1/me` routes** with `Authorization: Bearer ` so the user can read or manage their own profile. See [API reference: me](https://docs.facecard.dev/docs/api-reference/me).
Do not accept a token that your server has not verified. A token that arrived from the browser is just a string until you check it.
## Verify online
`POST /v1/tokens/verify` with your secret key returns `{ "valid": true, "claims": {...} }` or `{ "valid": false, "reason": "..." }`. See [API reference: tokens](https://docs.facecard.dev/docs/api-reference/tokens).
## Verify offline with JWKS
The public signing keys are published at:
```text
https://api.facecard.dev/.well-known/jwks.json
```
Any JWT library can verify the token against that key set:
```ts
import { createRemoteJWKSet, jwtVerify } from 'jose';
const jwks = createRemoteJWKSet(new URL('https://api.facecard.dev/.well-known/jwks.json'));
const { payload } = await jwtVerify(token, jwks, {
issuer: 'https://api.facecard.dev',
audience: 'facecard',
});
// Also check payload.app_id equals your app id.
```
Details to respect:
- Choose the key by the token's `kid`. The signing key rotates, and during a rotation the set holds both the current and the previous public key.
- Cache the JWKS (the endpoint sends a short cache lifetime) and refetch when you see an unknown `kid`.
- Check `app_id`. A token from another FaceCard app is a valid FaceCard token but not a user of your app.
- Offline verification cannot know that a user was deleted or signed out after the token was issued. A token revoked with `POST /v1/me/logout` still passes the JWKS check until it expires. `POST /v1/tokens/verify` (`tokens.verify` in the SDK) is the revocation aware check. For sensitive actions use it, or keep your own session short.
The SDK does this for you with `verifyTokenOffline(token)`. See [server SDK](https://docs.facecard.dev/docs/sdk/server).
## Signing out
`POST /v1/me/logout` puts the token's `jti` on a revocation list until the token expires. The API then rejects it with `401 token_revoked`, and `POST /v1/tokens/verify` returns `{ "valid": false, "reason": "revoked" }`. See [`/v1/me` routes](https://docs.facecard.dev/docs/api-reference/me#sign-out).
## Cookie sessions
First party apps (the FaceCard consumer app itself) also receive an HttpOnly `fc_session` cookie. Third party apps get the token in the response body only.
---
# Challenges and replay protection
Source: https://docs.facecard.dev/docs/concepts/challenges
A **challenge** is a one-time nonce issued by the API. Every `POST /v1/enroll`, `POST /v1/match` and `POST /v1/me/templates` call must include a `challenge_id` that was just issued for the same app.
```bash
curl -X POST https://api.facecard.dev/v1/challenges \
-H "X-FaceCard-Key: fc_pk_live_xxx" \
-H "Origin: https://app.example.com"
```
```json
{
"challenge_id": "ch_...",
"nonce": "<32 bytes, base64url>",
"expires_at": "2026-10-14T12:00:00.000Z"
}
```
## Rules
- **Single use.** A challenge can be used for one call. Request a new one for every attempt.
- **60 second lifetime.** An expired challenge is rejected. Expired challenges are deleted hourly.
- **Bound to the app.** A challenge issued for one app cannot be used with another.
- A challenge is required for every enroll and match, so a captured request body cannot be replayed later to sign someone in.
## Why this matters
A face descriptor is just 128 numbers. Without a challenge, anyone who copied the request body of a real sign-in could post it again. The challenge makes each sign-in request unique and short lived, so a recorded request is useless.
Challenges do not prove a live human is in front of the camera. For what the browser checks, see [Liveness is advisory](https://docs.facecard.dev/docs/concepts/liveness).
## In the SDK
The SDK requests a challenge for you on every `enroll()` and `signIn()`. You only handle challenges yourself when you call the REST API directly.
---
# Matching: threshold and margin
Source: https://docs.facecard.dev/docs/concepts/matching
## Descriptors
The browser SDK uses `@vladmandic/face-api` to turn a face into a **128-float descriptor**. The API checks that each descriptor has exactly 128 finite numbers, L2-normalizes it, and stores it in a Postgres `vector(128)` column with an HNSW cosine index. Searches are always filtered by `app_id`, so a match can only be a user of your app.
## Threshold
A candidate counts as a match only if its **cosine distance** to the probe is below the match threshold. The default is **0.35**. A lower number is stricter: fewer false accepts, more false rejects.
The threshold is a server setting (`MATCH_MAX_DISTANCE`), not something a client can change. Apps cannot set it.
## Margin
Being close is not enough. The best candidate must also beat the **second best different user** by a margin, which defaults to **0.04**. If two users look alike to the model and neither clearly wins, the result is `no_match` instead of a guess.
## What you get back
- A match returns `200` with `user`, `token`, `expires_at` and a `confidence` value between 0.0 and 1.0. The user in the response is the owner of the matched template, and that is the user who is signed in.
- No match returns `401 no_match` with a generic message. The response never contains an email, a name or any candidate data.
## Duplicate faces at enrollment
During enrollment the API runs the same comparison against existing users in your app. If the new face matches an existing user above the threshold, enrollment fails with `409 face_already_enrolled`. The response does not say who the other user is.
## Templates over time
A user can have up to **10 templates**. After a fresh successful match, the user can add up to 3 more descriptors with `POST /v1/me/templates`, which helps with lighting and appearance changes. When the cap is reached the oldest learned template is dropped first.
## Tuning
Matching quality depends on the capture. Good light, a face centered in the frame, no heavy glare and a few seconds of video give the best descriptors. If real users are rejected often, ask them to retry in better light before you look at anything else.
---
# Liveness is advisory
Source: https://docs.facecard.dev/docs/concepts/liveness
Be clear about this when you decide where to use FaceCard.
**Liveness is computed in the browser, and it is advisory.** The SDK looks for signs of a live face, such as a blink or head motion, and sends a `liveness` object (a score and the checks it ran) with `enroll` and `match`. Because the check runs on the user's own device, a determined attacker who controls that device can fake the result. The server does not treat it as proof.
## What it does
- Makes casual attacks harder, such as holding up a photo.
- Gives you a signal you can log and review.
## What it does not do
- It is not presentation attack detection. It does not stop video replay, a modified client, or someone injecting descriptors directly into the API.
- It does not replace a second factor for high-risk actions.
## What protects you in practice
- **Challenges** stop a recorded request from being replayed (see [Challenges](https://docs.facecard.dev/docs/concepts/challenges)).
- **Rate limits** slow down guessing (30 requests a minute per IP on the face routes).
- **The matching threshold and margin** reject faces that are not a clear match (see [Matching](https://docs.facecard.dev/docs/concepts/matching)).
- **Your own policy.** For money movement, account recovery or admin access, require another factor in addition to a FaceCard sign-in.
FaceCard is a convenient, passwordless way to identify a returning user. Treat it as a strong convenience factor, and add a stronger check where the cost of a wrong accept is high.
---
# API reference
Source: https://docs.facecard.dev/docs/api-reference
## Base URL
```text
https://api.facecard.dev
```
All routes are under `/v1`, except `/health`, `/openapi.json` and `/.well-known/jwks.json`. Requests and responses are JSON. The same contract is served as OpenAPI 3.1 at `https://api.facecard.dev/openapi.json`.
## Credentials
| Credential | Header | Use |
|---|---|---|
| Publishable key `fc_pk_live_xxx` | `X-FaceCard-Key: fc_pk_live_xxx` plus an allowed `Origin` | Browser routes: challenges, enroll, match |
| Secret key `fc_sk_live_xxx` | `Authorization: Bearer fc_sk_live_xxx` | Server routes: users, token verification, usage |
| User token (ES256 JWT) | `Authorization: Bearer ` | `/v1/me` routes for that one user |
The app is always derived from the credential. A request body `app_id` is ignored. See [Apps and keys](https://docs.facecard.dev/docs/concepts/apps-and-keys).
In the curl examples on these pages, `fc_pk_live_xxx` and `fc_sk_live_xxx` are placeholders. Use the keys from your own app. Browser routes need an `Origin` header that matches your allow list, so the examples send one.
## Errors
Every error has the same shape and a matching HTTP status:
```json
{ "error": { "code": "no_match", "message": "No matching face was found." } }
```
Some errors carry extra fields, for example `limit`, `used` and `resets_at` on `plan_limit_reached`. The full list is in [Errors](https://docs.facecard.dev/docs/errors).
## Routes
| Method and path | Credential | Page |
|---|---|---|
| `POST /v1/challenges` | Publishable key | [Challenges](https://docs.facecard.dev/docs/api-reference/challenges) |
| `POST /v1/enroll` | Publishable key | [Enroll](https://docs.facecard.dev/docs/api-reference/enroll) |
| `POST /v1/match` | Publishable key | [Match](https://docs.facecard.dev/docs/api-reference/match) |
| `GET /v1/me`, `PATCH /v1/me`, `DELETE /v1/me` | User token | [Me](https://docs.facecard.dev/docs/api-reference/me) |
| `POST /v1/me/email`, `POST /v1/me/email/confirm` | User token | [Me](https://docs.facecard.dev/docs/api-reference/me#change-email) |
| `POST /v1/me/templates` | User token | [Me](https://docs.facecard.dev/docs/api-reference/me#add-templates) |
| `GET /v1/users`, `GET /v1/users/{id}`, `DELETE /v1/users/{id}` | Secret key | [Users](https://docs.facecard.dev/docs/api-reference/users) |
| `POST /v1/tokens/verify` | Secret key | [Tokens](https://docs.facecard.dev/docs/api-reference/tokens) |
| `GET /v1/usage` | Secret key | [Usage](https://docs.facecard.dev/docs/api-reference/usage) |
| `GET /health`, `GET /.well-known/jwks.json`, `GET /openapi.json` | None | [Health, JWKS and OpenAPI](https://docs.facecard.dev/docs/api-reference/meta-routes) |
The `/internal/*` routes are used by the developer portal and are not available from the internet. They are not part of the public API.
## Rate limits
30 requests a minute per IP on challenge, enroll and match. 600 requests a minute per secret key. Over the limit you get `429 rate_limited` with a `Retry-After` header. See [Plans and limits](https://docs.facecard.dev/docs/plans-and-limits#rate-limits).
---
# POST /v1/challenges
Source: https://docs.facecard.dev/docs/api-reference/challenges
`POST /v1/challenges`
Issues a single-use challenge bound to your app. It expires after 60 seconds. Every enroll and match needs a fresh one. See [Challenges and replay protection](https://docs.facecard.dev/docs/concepts/challenges).
**Credential:** publishable key, from an allowed Origin. No request body.
## Request
```bash
curl -X POST https://api.facecard.dev/v1/challenges \
-H "X-FaceCard-Key: fc_pk_live_xxx" \
-H "Origin: https://app.example.com"
```
## Response
`200 OK`
```json
{
"challenge_id": "ch_...",
"nonce": "Zm9vYmFy...",
"expires_at": "2026-10-14T12:01:00.000Z"
}
```
| Field | Type | Description |
|---|---|---|
| `challenge_id` | string | Pass this as `challenge_id` to enroll or match. |
| `nonce` | string | 32 random bytes, base64url. |
| `expires_at` | string | ISO 8601 time. 60 seconds after issue. |
## Errors
| Status | Code | When |
|---|---|---|
| 401 | `unauthorized` | Missing or invalid key. |
| 403 | `origin_not_allowed` | The Origin is not on the app's allow list. |
| 429 | `rate_limited` | More than 30 requests a minute from this IP. |
| 429 | `plan_limit_reached` | The team is over its monthly auth limit. |
---
# POST /v1/enroll
Source: https://docs.facecard.dev/docs/api-reference/enroll
`POST /v1/enroll`
Creates a user in your app and stores their face templates. Returns the user and a user token, so the user is signed in right after enrolling.
**Credential:** publishable key, from an allowed Origin. **Body limit:** 256 KB.
## Request body
```json
{
"challenge_id": "ch_...",
"email": "ada@example.com",
"name": "Ada",
"descriptors": [[0.012, -0.044, "... 128 floats in total"]],
"liveness": { "score": 0.93, "checks": ["blink", "motion"] },
"consent": { "policy_version": "2026-10-14", "accepted": true }
}
```
| Field | Type | Rules |
|---|---|---|
| `challenge_id` | string | A fresh challenge from `POST /v1/challenges`. |
| `email` | string | Must be unique in this app. |
| `name` | string | Display name. |
| `descriptors` | array | 1 to 5 arrays, each exactly 128 finite floats. |
| `liveness` | object | Browser liveness result. Advisory, see [Liveness](https://docs.facecard.dev/docs/concepts/liveness). |
| `consent` | object | `accepted` must be `true`. FaceCard stores the policy version, time, IP and user agent. |
Keep the real request body in a file because descriptors are long:
```bash
curl -X POST https://api.facecard.dev/v1/enroll \
-H "X-FaceCard-Key: fc_pk_live_xxx" \
-H "Origin: https://app.example.com" \
-H "Content-Type: application/json" \
-d @enroll.json
```
## Response
`201 Created`
```json
{
"user": {
"id": "usr_...",
"email": "ada@example.com",
"name": "Ada",
"created_at": "2026-10-14T12:00:00.000Z"
},
"token": "",
"expires_at": "2026-10-14T13:00:00.000Z"
}
```
## Errors
| Status | Code | When |
|---|---|---|
| 400 | `invalid_request` | Malformed body, wrong descriptor count or length. |
| 400 | `consent_required` | `consent.accepted` is not `true`. |
| 400 | `challenge_invalid` | The challenge is missing, expired, already used or from another app. |
| 401 | `unauthorized` | Missing or invalid key. |
| 403 | `origin_not_allowed` | The Origin is not on the allow list. |
| 409 | `email_taken` | That email already exists in this app. |
| 409 | `face_already_enrolled` | The face matches an existing user in this app. The response never names the other user. |
| 413 | `payload_too_large` | Body over 256 KB. |
| 429 | `rate_limited` | More than 30 requests a minute from this IP. |
| 429 | `plan_limit_reached` | Over the monthly auth limit. Each enroll that reaches face matching counts as one auth. |
---
# POST /v1/match
Source: https://docs.facecard.dev/docs/api-reference/match
`POST /v1/match`
Compares 1 to 3 descriptors with the templates enrolled in your app. On a match, the owner of the matched template is signed in and receives a user token.
**Credential:** publishable key, from an allowed Origin.
## Request body
```json
{
"challenge_id": "ch_...",
"descriptors": [[0.012, -0.044, "... 128 floats in total"]],
"liveness": { "score": 0.93, "checks": ["blink", "motion"] }
}
```
| Field | Type | Rules |
|---|---|---|
| `challenge_id` | string | A fresh challenge. |
| `descriptors` | array | 1 to 3 arrays, each exactly 128 finite floats. |
| `liveness` | object | Advisory browser result. |
```bash
curl -X POST https://api.facecard.dev/v1/match \
-H "X-FaceCard-Key: fc_pk_live_xxx" \
-H "Origin: https://app.example.com" \
-H "Content-Type: application/json" \
-d @match.json
```
## Response
`200 OK`
```json
{
"user": {
"id": "usr_...",
"email": "ada@example.com",
"name": "Ada",
"created_at": "2026-10-14T12:00:00.000Z"
},
"token": "",
"expires_at": "2026-10-14T13:00:00.000Z",
"confidence": 0.91
}
```
`confidence` is between 0.0 and 1.0. Matching rules (threshold and margin) are explained in [Matching](https://docs.facecard.dev/docs/concepts/matching).
## No match
`401 Unauthorized`
```json
{ "error": { "code": "no_match", "message": "No matching face was found." } }
```
The message is generic on purpose. It never includes an email or any candidate data.
## Errors
| Status | Code | When |
|---|---|---|
| 400 | `invalid_request` | Malformed body, wrong descriptor count or length. |
| 400 | `challenge_invalid` | The challenge is missing, expired, already used or from another app. |
| 401 | `unauthorized` | Missing or invalid key. |
| 401 | `no_match` | No candidate passed the threshold and margin. |
| 403 | `origin_not_allowed` | The Origin is not on the allow list. |
| 429 | `rate_limited` | More than 30 requests a minute from this IP. |
| 429 | `plan_limit_reached` | Over the monthly auth limit. A match that reaches face matching counts as one auth, success or failure. |
---
# /v1/me routes
Source: https://docs.facecard.dev/docs/api-reference/me
These routes act on the one user who owns the token. Send the user token as `Authorization: Bearer `.
```bash
export TOKEN=""
```
## Get the profile
`GET /v1/me`
```bash
curl https://api.facecard.dev/v1/me -H "Authorization: Bearer $TOKEN"
```
Returns the profile, the enrollment date, the number of face templates and the consent record (policy version and time). It never returns descriptors.
## Update the name
`PATCH /v1/me` with `{ "name": "Ada L." }`. Only `name` can be changed this way.
```bash
curl -X PATCH https://api.facecard.dev/v1/me \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Ada L."}'
```
## Change email
Changing the email is a two step flow.
1. `POST /v1/me/email` with `{ "new_email": "ada@new.example" }`. FaceCard sends a 6 digit code to the **new** address. The code expires after 15 minutes.
2. `POST /v1/me/email/confirm` with `{ "code": "123456" }` applies the change.
```bash
curl -X POST https://api.facecard.dev/v1/me/email \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"new_email":"ada@new.example"}'
curl -X POST https://api.facecard.dev/v1/me/email/confirm \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"code":"123456"}'
```
Errors: `409 email_taken` if the new address already exists in this app, `400 invalid_code` for a wrong or expired code.
## Add templates
`POST /v1/me/templates`
Adds 1 to 3 more descriptors to the user's face templates after a **fresh match**. It needs a new challenge, the same as `/v1/match`. A user keeps at most 10 templates, and the oldest learned template is dropped first.
```bash
curl -X POST https://api.facecard.dev/v1/me/templates \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @templates.json
```
`templates.json` holds `challenge_id`, `descriptors` (1 to 3 arrays of 128 floats) and `liveness`, as in [match](https://docs.facecard.dev/docs/api-reference/match).
## Sign out
`POST /v1/me/logout`
Ends this session on the server. The token's `jti` goes on a revocation list until the token would have expired anyway, and for first party apps the `fc_session` cookie is cleared. Returns `204 No Content`. Send the token as a Bearer header or, for first party apps, in the `fc_session` cookie.
```bash
curl -X POST https://api.facecard.dev/v1/me/logout -H "Authorization: Bearer $TOKEN"
```
After that, the same token gets `401 token_revoked` on every `/v1/me` route, and `POST /v1/tokens/verify` reports `{ "valid": false, "reason": "revoked" }`. Calling logout again with the same token also returns `401 token_revoked`, so treat 204 and 401 as "signed out". Other sessions of the same user keep working.
**Offline verification cannot see a sign out.** Checking a token against the public keys at `/.well-known/jwks.json` only proves the signature and expiry. A signed out token still passes that check until it expires (up to one hour). `POST /v1/tokens/verify` is the revocation aware check. Use it for anything sensitive, or keep your own session short.
## Delete the account
`DELETE /v1/me`
Deletes the profile, every face template, the consent record and challenge rows for that user. It writes a tombstone auth event that contains no personal data. Returns `204 No Content`. This cannot be undone.
```bash
curl -X DELETE https://api.facecard.dev/v1/me -H "Authorization: Bearer $TOKEN"
```
## Errors
| Status | Code | When |
|---|---|---|
| 400 | `invalid_request` | Bad body, for example a missing `name`. |
| 400 | `invalid_code` | Wrong or expired email confirmation code. |
| 400 | `challenge_invalid` | Template add with a bad challenge. |
| 401 | `unauthorized` | Missing, invalid or expired user token. |
| 401 | `token_revoked` | The token was signed out with `POST /v1/me/logout`. |
| 404 | `not_found` | The user no longer exists. |
| 409 | `email_taken` | New email already used in this app. |
| 429 | `rate_limited` | Too many requests. |
---
# /v1/users routes
Source: https://docs.facecard.dev/docs/api-reference/users
These routes use a secret key: `Authorization: Bearer fc_sk_live_xxx`. They only see users of the app that owns the key, and they never return face descriptors.
## The user object
```json
{
"id": "usr_...",
"email": "ada@example.com",
"name": "Ada",
"created_at": "2026-10-14T12:00:00.000Z",
"last_sign_in_at": "2026-10-14T12:30:00.000Z"
}
```
`last_sign_in_at` is `null` if the user has not signed in since enrolling.
## List users
`GET /v1/users?limit=50&cursor=...`
| Query | Description |
|---|---|
| `limit` | 1 to 100. Default 50. |
| `cursor` | The `next_cursor` from the previous page. |
```bash
curl "https://api.facecard.dev/v1/users?limit=50" \
-H "Authorization: Bearer fc_sk_live_xxx"
```
```json
{
"users": [ { "id": "usr_...", "email": "ada@example.com", "name": "Ada", "created_at": "...", "last_sign_in_at": null } ],
"next_cursor": ""
}
```
`next_cursor` is `null` on the last page. Users are ordered by creation time.
## Get a user
`GET /v1/users/{id}` returns one user object.
```bash
curl https://api.facecard.dev/v1/users/usr_123 \
-H "Authorization: Bearer fc_sk_live_xxx"
```
## Delete a user
`DELETE /v1/users/{id}` deletes the user, every face template, the consent record and challenge rows. A tombstone auth event without personal data is kept. Returns `204 No Content`.
```bash
curl -X DELETE https://api.facecard.dev/v1/users/usr_123 \
-H "Authorization: Bearer fc_sk_live_xxx"
```
Use this to honor a deletion request from one of your users. See [Security and privacy](https://docs.facecard.dev/docs/security-and-privacy#deletion).
## Errors
| Status | Code | When |
|---|---|---|
| 400 | `invalid_request` | Bad `limit` or `cursor`. |
| 401 | `unauthorized` | Missing or invalid secret key. |
| 403 | `origin_not_allowed` | The request carries a browser Origin that is not on the allow list. |
| 404 | `not_found` | No such user in this app. |
| 429 | `rate_limited` | More than 600 requests a minute for this key. |
---
# POST /v1/tokens/verify
Source: https://docs.facecard.dev/docs/api-reference/tokens
`POST /v1/tokens/verify`
Checks a user token's signature, expiry, app and whether it was signed out. **Credential:** secret key.
## Request
```bash
curl -X POST https://api.facecard.dev/v1/tokens/verify \
-H "Authorization: Bearer fc_sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{"token":""}'
```
## Response
`200 OK` for a valid token:
```json
{
"valid": true,
"claims": {
"iss": "https://api.facecard.dev",
"aud": "facecard",
"sub": "usr_...",
"app_id": "app_...",
"email": "ada@example.com",
"iat": 1792000000,
"exp": 1792003600,
"jti": "..."
}
}
```
`200 OK` for a token that fails verification:
```json
{ "valid": false, "reason": "expired" }
```
`reason` is a short string that says why the token was rejected: `expired`, `invalid`, `revoked` (the user signed out with [`POST /v1/me/logout`](https://docs.facecard.dev/docs/api-reference/me#sign-out)) or `user_not_found`. Branch on `valid`; use `reason` for messages and logs.
This is the revocation aware check. Offline verification against the JWKS cannot see a sign out.
## Errors
| Status | Code | When |
|---|---|---|
| 400 | `invalid_request` | `token` is missing. |
| 401 | `unauthorized` | Missing or invalid secret key. |
| 429 | `rate_limited` | More than 600 requests a minute for this key. |
To verify without calling the API, use the public keys. See [User tokens and JWKS](https://docs.facecard.dev/docs/concepts/user-tokens#verify-offline-with-jwks).
---
# GET /v1/usage
Source: https://docs.facecard.dev/docs/api-reference/usage
`GET /v1/usage?month=2026-10`
Returns usage counts for one calendar month. **Credential:** secret key.
```bash
curl "https://api.facecard.dev/v1/usage?month=2026-10" \
-H "Authorization: Bearer fc_sk_live_xxx"
```
```json
{
"month": "2026-10",
"auths": 412,
"limit": 1000,
"enrolls": 37,
"matches_ok": 351,
"matches_failed": 24
}
```
| Field | Description |
|---|---|
| `month` | The month, `YYYY-MM`. |
| `auths` | Enroll and match calls that reached face matching, success or failure. This is the number your plan limit counts. |
| `limit` | Your plan's auths per month. |
| `enrolls` | Enrollments. |
| `matches_ok` | Matches that signed a user in. |
| `matches_failed` | Matches that returned `no_match`. |
## Errors
| Status | Code | When |
|---|---|---|
| 400 | `invalid_request` | `month` is not in `YYYY-MM` form. |
| 401 | `unauthorized` | Missing or invalid secret key. |
| 429 | `rate_limited` | More than 600 requests a minute for this key. |
---
# Health, JWKS and OpenAPI
Source: https://docs.facecard.dev/docs/api-reference/meta-routes
## Health
`GET /health`
```bash
curl https://api.facecard.dev/health
```
```json
{ "status": "ok", "db": "ok", "version": "" }
```
Returns `200` when the database answers. If the database is down it returns `503` with `"db": "down"`. This route is outside `/v1` and has no authentication, so it is safe for uptime monitors.
## JWKS
`GET /.well-known/jwks.json`
The public keys that verify user tokens. The signing key rotates, so select the key by `kid`. During a rotation the set holds the current and the previous public key. The response is cacheable for a few minutes. See [User tokens and JWKS](https://docs.facecard.dev/docs/concepts/user-tokens).
```bash
curl https://api.facecard.dev/.well-known/jwks.json
```
## OpenAPI
`GET /openapi.json` returns the OpenAPI 3.1 description of this API. Use it to generate a client or to diff against these docs.
```bash
curl https://api.facecard.dev/openapi.json
```
---
# Errors
Source: https://docs.facecard.dev/docs/errors
Every error response is JSON with a correct HTTP status:
```json
{ "error": { "code": "snake_case_code", "message": "Human readable text." } }
```
Branch on `code`, not on `message`. The message can change. Some errors add fields next to `code` and `message`; those are listed below.
## Codes
| Status | Code | Meaning | What to do |
|---|---|---|---|
| 400 | `invalid_request` | The request is malformed: a field is missing or has the wrong type, a descriptor does not have exactly 128 finite numbers, or a query value is out of range. | Fix the request. The message names the field. |
| 400 | `invalid_json` | The body is not valid JSON. | Send valid JSON with `Content-Type: application/json`. |
| 400 | `consent_required` | Enrollment without `consent.accepted: true`. | Show your privacy notice and get consent first. |
| 400 | `challenge_invalid` | The challenge is missing, expired, already used or belongs to another app. | Request a new challenge and retry. |
| 400 | `invalid_code` | The email change confirmation code is wrong or expired (15 minutes). | Start the email change again. |
| 401 | `unauthorized` | The credential is missing, invalid, revoked or expired. | Check the key or token. A user token lasts one hour. |
| 401 | `no_match` | No enrolled face matched clearly enough. | Let the user retry in better light. The response carries no candidate data. |
| 403 | `forbidden` | This credential cannot call this route, for example a publishable key on `/v1/users`. | Use the right credential for the route. |
| 403 | `origin_not_allowed` | The request Origin is not on the app's allow list. | Add the exact origin in the portal. |
| 403 | `plan_limit_reached` | You hit a plan count limit such as the number of apps or keys. Includes `limit` and `used`. | Remove something or upgrade your plan. |
| 404 | `not_found` | The user or resource does not exist in this app. | Check the id. |
| 409 | `email_taken` | The email already exists in this app. | Use a different email, or sign the user in instead. |
| 409 | `face_already_enrolled` | The face matches an existing user in this app. The response never names that user. | Sign in as the existing user. |
| 413 | `payload_too_large` | The body is over the limit (256 KB on `/v1/enroll`). | Send fewer descriptors. |
| 429 | `plan_limit_reached` | The team used all its auths for the month. Includes `limit`, `used` and `resets_at`. | Wait until `resets_at` or upgrade. |
| 429 | `rate_limited` | Too many requests. A `Retry-After` header gives the seconds to wait. | Back off and retry. |
| 500 | `internal_error` | A fault on our side. The response contains no details. | Retry. If it persists, contact support with the time of the call. |
| 503 | none | `GET /health` reports `db: "down"`. | Retry later. |
## Rate limit and plan limit examples
```json
{
"error": {
"code": "plan_limit_reached",
"message": "Monthly auth limit reached.",
"limit": 1000,
"used": 1000,
"resets_at": "2026-11-01T00:00:00.000Z"
}
}
```
`plan_limit_reached` uses two statuses. An exhausted monthly auth allowance is `429`. A count limit (number of apps or keys per plan) is `403`. See [Plans and limits](https://docs.facecard.dev/docs/plans-and-limits).
## In the SDK
SDK calls throw `FaceCardError`. It has the same `code` and the HTTP `status`. See [FaceCardError](https://docs.facecard.dev/docs/sdk/errors).
---
# Plans and limits
Source: https://docs.facecard.dev/docs/plans-and-limits
## Plans
| Plan | Price | Apps | Keys per app | Auths per month per team | Team members |
|---|---|---|---|---|---|
| Free | 0 | 1 | 2 | 1,000 | 1 |
| Pro | 29 USD per month | 10 | 25 | 50,000 | 10 |
| Enterprise | 99 USD per month | Unlimited | Unlimited | 500,000 (more on request) | Unlimited |
Limits are per team. A plan applies to every app of the team. You change plans in the [developer portal](https://developers.facecard.dev).
## What counts as an auth
An **auth** is one enroll or match call that reaches face matching, whether it succeeds or fails. Calls rejected earlier (bad credential, missing consent, invalid challenge, rate limit) do not count. Challenge requests are not auths.
Check your count with `GET /v1/usage` or in the portal. See [usage](https://docs.facecard.dev/docs/api-reference/usage).
## When you hit a limit
- **Monthly auths used up:** `429 plan_limit_reached` with `limit`, `used` and `resets_at`. It resets at the time in `resets_at`.
- **App or key count reached:** `403 plan_limit_reached` with `limit` and `used`.
## Rate limits
Rate limits are separate from plan limits and apply on every plan.
| Scope | Limit |
|---|---|
| Challenge, enroll and match, per IP address | 30 requests a minute |
| Per secret key | 600 requests a minute |
Over the limit the API answers `429 rate_limited` with a `Retry-After` header. Wait that many seconds before you retry.
## What is not offered
FaceCard does not offer webhooks, SSO, custom branding or advanced analytics at launch. No plan includes them.
---
# Security and privacy
Source: https://docs.facecard.dev/docs/security-and-privacy
## What is stored
FaceCard stores embeddings, not images.
- **No face images.** The browser computes a 128-number descriptor on the user's device. Images and video frames are never uploaded and never stored.
- **Face templates.** Descriptors are L2-normalized and stored in Postgres (`vector(128)`), scoped to one app.
- **Profile.** Email and name, per app.
- **Consent record.** Policy version, time, IP address and user agent.
- **Auth events.** A log of enrolls and matches. It holds no descriptors.
- **Challenges.** Single-use nonces, short lived.
Data is held in AWS in the `us-east-2` region. Secret keys and publishable keys are stored only as SHA-256 hashes.
## Retention schedule
| Data | Kept until |
|---|---|
| A user's biometric data (templates) | The user or the app deletes the account, the app is deleted, or 3 years pass with no sign in, whichever comes first |
| Auth events | Deleted after 400 days |
| Expired challenges | Deleted hourly |
A daily purge job enforces these rules. The public schedule is at `https://facecard.dev/privacy#retention`.
## Consent
FaceCard processes biometric data, so enrollment requires explicit consent. `POST /v1/enroll` fails with `400 consent_required` unless `consent.accepted` is `true`. Your app must show the user a privacy notice that explains what is collected before you send that value. FaceCard stores the policy version, time, IP address and user agent with the user.
Consent law differs by place. Some jurisdictions have specific biometric privacy rules. Read the rules that apply to your users and consult your own counsel. These docs are not legal advice.
## Deletion
- A user can delete themselves with `DELETE /v1/me` using their token.
- Your server can delete any user of the app with `DELETE /v1/users/{id}`.
- Deleting an app removes its users and revokes all its keys.
A deletion removes the profile, every face template, the consent record and challenge rows for that user. FaceCard keeps a tombstone auth event that contains no personal data. Deletion cannot be undone.
## Isolation
Every query is scoped by app. A credential belongs to one app, and the app is always derived from the credential, never from the request body. Face search is filtered by `app_id`, so a face enrolled in one app can never match in another.
## Protections on the API
- Single-use challenges stop replay of recorded requests.
- Publishable keys only work from the origins you allow.
- Rate limits apply per IP and per secret key.
- User tokens are ES256 JWTs that last one hour.
- Error responses for failed matches carry no candidate data.
## What FaceCard does not protect against
Liveness is computed in the browser and is advisory, so FaceCard is not a defense against a determined spoofing attack. See [Liveness is advisory](https://docs.facecard.dev/docs/concepts/liveness).
## Report a problem
Security issues: security@facecard.dev. Privacy requests: privacy@facecard.dev.
---
# SDK reference
Source: https://docs.facecard.dev/docs/sdk
```bash
npm install @facecard/sdk
```
`@facecard/sdk` has three parts. Each has its own page.
| Part | Import | Where it runs | Credential |
|---|---|---|---|
| [Browser client](https://docs.facecard.dev/docs/sdk/browser) | `createFaceCard` from `@facecard/sdk` | Browser | Publishable key |
| [Server client](https://docs.facecard.dev/docs/sdk/server) | `createFaceCardServer` from `@facecard/sdk` | Node | Secret key |
| [React](https://docs.facecard.dev/docs/sdk/react) | `FaceCardProvider`, `useFaceCard` from `@facecard/sdk/react` | Browser | Publishable key |
All calls reject with a [`FaceCardError`](https://docs.facecard.dev/docs/sdk/errors), which carries a `code` and an HTTP `status`.
Never ship a secret key to the browser. Never call `createFaceCardServer` in client code.
The SDK is a thin layer over the [REST API](https://docs.facecard.dev/docs/api-reference). Anything it does you can do with curl.
---
# Browser client
Source: https://docs.facecard.dev/docs/sdk/browser
## createFaceCard
```ts
import { createFaceCard } from '@facecard/sdk';
const fc = createFaceCard({ publishableKey: 'fc_pk_live_xxx' });
```
| Option | Type | Description |
|---|---|---|
| `publishableKey` | string | Your `fc_pk_live_...` key. The page origin must be on the app's allow list. |
The client asks for the camera when you call `enroll` or `signIn`. It requests a fresh challenge for each call and computes the face descriptors in the browser. No image leaves the device.
## enroll
```ts
const { user, token } = await fc.enroll({
email: 'ada@example.com',
name: 'Ada',
consent: { policy_version: '2026-10-14', accepted: true },
});
```
| Field | Type | Description |
|---|---|---|
| `email` | string | Unique in the app. |
| `name` | string | Display name. |
| `consent` | `{ policy_version: string, accepted: true }` | Pass it only after the user accepted your privacy notice; `policy_version` names that notice. Otherwise the call fails with `consent_required` before any network request. |
Resolves with the created `user` (`id`, `email`, `name`, `created_at`) and a `token`, the same data as `POST /v1/enroll`. Rejects with `email_taken` or `face_already_enrolled` on a conflict.
## signIn
```ts
const { user, token } = await fc.signIn();
```
Resolves with the matched `user` and a `token`, like `POST /v1/match`. Rejects with `no_match` when no enrolled face matched clearly enough.
## signOut
```ts
await fc.signOut();
```
Calls `POST /v1/me/logout` so the server revokes the token, then always clears the token the client holds. It is best effort: a network error, a timeout (3 seconds by default, change it with `{ timeoutMs }`) or a 401 is swallowed and it never throws. It resolves `true` when the server confirmed the sign out and `false` otherwise, so you can drop your own session state right after it without waiting on the result. Pass `{ token }` to sign out a token you hold yourself; the client's stored token is then left alone.
Offline JWKS verification on your server cannot see a sign out. Use `tokens.verify` (the revocation aware check) when that matters.
## Verify on your server
The returned `token` is only a claim until your server checks it. See [Quickstart: server](https://docs.facecard.dev/docs/quickstart-server).
## Handling errors
```ts
import { FaceCardError } from '@facecard/sdk';
try {
await fc.signIn();
} catch (err) {
if (err instanceof FaceCardError) {
switch (err.code) {
case 'no_match': /* ask the user to retry */ break;
case 'rate_limited': /* wait before retrying */ break;
default: console.error(err.code, err.status);
}
}
}
```
---
# Server client
Source: https://docs.facecard.dev/docs/sdk/server
```ts
import { createFaceCardServer } from '@facecard/sdk';
const facecard = createFaceCardServer({ secretKey: process.env.FACECARD_SECRET_KEY! });
```
| Option | Type | Description |
|---|---|---|
| `secretKey` | string | Your `fc_sk_live_...` key. Server only. |
## users
| Method | API route | Returns |
|---|---|---|
| `users.list({ limit, cursor })` | `GET /v1/users` | `{ users, next_cursor }` |
| `users.get(id)` | `GET /v1/users/{id}` | The user |
| `users.delete(id)` | `DELETE /v1/users/{id}` | Nothing (the user and their face templates are gone) |
```ts
const page = await facecard.users.list({ limit: 50 });
const user = await facecard.users.get('usr_123');
await facecard.users.delete('usr_123');
```
## tokens.verify
```ts
const result = await facecard.tokens.verify(token);
if (result.valid) console.log(result.claims.sub);
else console.log(result.reason);
```
Calls `POST /v1/tokens/verify`. Resolves with `{ valid: true, claims }` or `{ valid: false, reason }`. The reason is `revoked` when the user signed out. This is the revocation aware check.
## verifyTokenOffline
```ts
const claims = await facecard.verifyTokenOffline(token);
```
Verifies the signature, issuer, audience and expiry against the public keys at `/.well-known/jwks.json`, with no call to the verify route. Resolves with the claims and rejects with a `FaceCardError` if the token is invalid or expired. It cannot see a deletion or a sign out that happened after the token was issued, so a signed out token still passes until it expires. Use `tokens.verify` when revocation matters. See [User tokens and JWKS](https://docs.facecard.dev/docs/concepts/user-tokens).
## usage
```ts
const usage = await facecard.usage('2026-10');
// { month, auths, limit, enrolls, matches_ok, matches_failed }
```
Calls `GET /v1/usage?month=...`.
---
# React
Source: https://docs.facecard.dev/docs/sdk/react
```bash
npm install @facecard/sdk
```
## FaceCardProvider
Wrap the part of your app that uses face sign-in.
```tsx
import { FaceCardProvider } from '@facecard/sdk/react';
export function Root({ children }: { children: React.ReactNode }) {
return {children};
}
```
| Prop | Type | Description |
|---|---|---|
| `publishableKey` | string | Your `fc_pk_live_...` key. |
## useFaceCard
Returns the browser client from the nearest provider, with `enroll` and `signIn`. See [Browser client](https://docs.facecard.dev/docs/sdk/browser).
```tsx
import { useFaceCard } from '@facecard/sdk/react';
import { FaceCardError } from '@facecard/sdk';
export function SignIn({ onToken }: { onToken: (token: string) => void }) {
const fc = useFaceCard();
return (
);
}
```
Call `useFaceCard` only inside a `FaceCardProvider`.
---
# FaceCardError
Source: https://docs.facecard.dev/docs/sdk/errors
```ts
import { FaceCardError } from '@facecard/sdk';
try {
await facecard.users.get('usr_missing');
} catch (err) {
if (err instanceof FaceCardError) {
console.log(err.code); // 'not_found'
console.log(err.status); // 404
console.log(err.message); // human readable text
}
}
```
| Property | Type | Description |
|---|---|---|
| `code` | string | The API error code, such as `no_match`, `consent_required` or `rate_limited`. See [Errors](https://docs.facecard.dev/docs/errors). |
| `status` | number | The HTTP status of the response. |
| `message` | string | Human readable text. Do not branch on it. |
Branch on `code`.
---
# Changelog
Source: https://docs.facecard.dev/docs/changelog
## 4.0.0 (2026-10-14)
The FaceCard relaunch: a new API and a new SDK.
### API v1
- New public API at `https://api.facecard.dev/v1`, described by OpenAPI 3.1 at `/openapi.json`.
- Secret keys (`fc_sk_live_...`) for servers and publishable keys (`fc_pk_live_...`) for browsers, restricted by an Origin allow list.
- Single-use challenges (60 second lifetime) required for every enroll and match.
- Matching on the API with a cosine distance threshold and a margin between the best and second best user.
- ES256 user tokens (one hour) with public keys at `/.well-known/jwks.json`.
- `POST /v1/me/logout` revokes the user token (a `jti` denylist). Revoked tokens get `401 token_revoked`, and `POST /v1/tokens/verify` returns `reason: "revoked"`. Offline JWKS verification cannot see revocation.
- Consent is required to enroll. Users can delete themselves with `DELETE /v1/me`.
- Plans: Free, Pro and Enterprise with per-team monthly auth limits and per-IP and per-key rate limits.
- Retention schedule: biometric data deleted on account or app deletion or after 3 years without sign in; auth events after 400 days.
### SDK `@facecard/sdk` 4.0
- `createFaceCard({ publishableKey })` with `enroll({ email, name, consent })` and `signIn()`.
- `createFaceCardServer({ secretKey })` with `users.list`, `users.get`, `users.delete`, `tokens.verify`, `usage(month)` and `verifyTokenOffline(token)`.
- React: `FaceCardProvider` and `useFaceCard`.
- `signOut()` on the browser client: best effort `POST /v1/me/logout`, then always clears the stored token. It never throws.
- `FaceCardError` with `code` and `status`.
### Breaking changes
- `@facecard/sdk` 4.0.0 is the first release of the FaceCard SDK.
- Old `fs_live` keys are gone. Create new keys in the portal.
- Users of the previous service must enroll again.