API Reference
Authentication · Errors · Rate Limiting
This is the narrative companion to the live OpenAPI docs (Swagger UI and ReDoc, served directly by the API) — it covers the things a schema alone doesn't explain: which auth scheme a given endpoint expects and why, what every error code actually means, and how rate limiting behaves. For request/response shapes, use the OpenAPI docs.
There is currently no SDK in any language — every endpoint is called with plain HTTP.
Authentication#
Three completely separate schemes exist. No endpoint accepts more than one, and there is no fallback between them.
1. X-API-Key — machine access to gallery-scoped operations#
Used by: /register, /recognize, /registrations*.
X-API-Key: sk_...The key itself encodes routing — it resolves server-side to a client_id, a gallery_id, and a rate_limit. You never specify a gallery or model in the request; the key determines it. Keys are managed via the JWT-protected management endpoints below (POST /galleries/{gallery_id}/api-keys), not created directly by callers.
Failure modes: 401 MISSING_API_KEY (no header), 401 INVALID_API_KEY (unknown/revoked key), 403 GALLERY_NOT_FOUND (the key's gallery was deleted), 403 CLIENT_SUSPENDED.
2. No auth — stateless operations#
Used by: /verify, /liveness.
Neither endpoint touches a gallery — /verify compares two images you supply directly, /liveness checks a single image — so there's no client or gallery to resolve a key against. These are open endpoints: no rate limiting and no per-client audit trail. If you need usage attribution for these two, handle it on your own side.
3. Authorization: Bearer <token> — human access to account management#
Used by: /galleries, /api-keys.
Authorization: Bearer <supabase-access-token>The token must be a genuine Supabase-issued access token (real login, not the anon/publishable key) — Face verifies it independently against Supabase's JWKS (ES256 signature, issuer, expiry). Face extracts only the token's sub claim and does nothing else with the token itself.
client_id and role are never read from the token. On every request, Face calls Core's entitlement service (POST /v1/internal/entitlements/check) with that sub and gets back client_id/role/allowed fresh — nothing is cached or persisted, and client_id is treated as fully opaque (never assumed to equal sub).
Failure modes, and they mean different things — don't treat them interchangeably:
- 401 — the token itself is bad: missing, expired, invalid signature, wrong issuer, or missing
sub. - 503 — Face couldn't get a clean answer from Core (unreachable, misconfigured, or an incomplete response). This is a fail-closed state, never treated as authorized.
- 403 NOT_ENTITLED — the token is valid and Core answered, but this account isn't entitled to use this product.
4. X-Admin-Secret — super-admin operations#
Used by: /admin/*.
X-Admin-Secret: ...A single shared secret for operator-level actions (managing any client/gallery/key, not just your own). Not tied to a specific client or gallery.
Error reference#
Every error response has the same shape:
{
"error": "invalid_model",
"message": "Unknown model_index=3. Available: [0, 1]",
"code": "INVALID_MODEL",
"request_id": "..."
}code is the stable, machine-matchable identifier — match on this, not on message (message text can change). request_id is present on most operational-endpoint errors and is useful when asking for help debugging a specific failed call.
| Status | code | Where | Meaning |
|---|---|---|---|
| 400 | INVALID_BASE64 | any image-accepting endpoint | image_b64 isn't valid base64 |
| 400 | INVALID_IMAGE_FORMAT | any image-accepting endpoint | Decoded image isn't JPEG/PNG/WebP |
| 400 | INVALID_MODEL | endpoints taking model_index | Unknown model index |
| 400 | INVALID_PERSON_ID | /register, registration lookups | person_id isn't a valid UUID |
| 400 | INVALID_STATUS | /admin/clients/{id} (PATCH) | Status must be active or suspended |
| 401 | MISSING_API_KEY | API-key endpoints | No X-API-Key header sent |
| 401 | INVALID_API_KEY | API-key endpoints | Key doesn't exist or was revoked |
| 401 | MISSING_TOKEN | JWT endpoints | No Authorization: Bearer header sent |
| 401 | TOKEN_EXPIRED | JWT endpoints | Token's exp has passed |
| 401 | INVALID_TOKEN | JWT endpoints | Signature/issuer/audience check failed |
| 401 | INVALID_TOKEN_CLAIMS | JWT endpoints | Token has no sub claim |
| 403 | FORBIDDEN | admin endpoints, ownership checks | Wrong admin secret, or the resource doesn't belong to your account |
| 403 | CLIENT_SUSPENDED | API-key and JWT endpoints | Client account is suspended |
| 403 | GALLERY_NOT_FOUND | API-key endpoints | The key's gallery was deleted/deactivated |
| 403 | NOT_ENTITLED | JWT endpoints | Core says this account isn't entitled to this product |
| 404 | NOT_FOUND | most GET/DELETE by ID | Client, gallery, key, or registration doesn't exist |
| 413 | IMAGE_TOO_LARGE | any image-accepting endpoint | Image exceeds MAX_IMAGE_BYTES |
| 429 | RATE_LIMIT_EXCEEDED | API-key endpoints | See Rate limiting below |
| 502 | REGISTRATION_FAILED | /register | Registration service call failed |
| 502 | RECOGNITION_FAILED | /recognize | Recognition service call failed |
| 502 | VERIFY_FAILED | /verify | Recognition service (embedding) call failed |
| 502 | LIVENESS_FAILED | /liveness | Liveness service returned an error |
| 502 | LIVENESS_UNAVAILABLE | /liveness | Liveness service unreachable |
| 503 | ENTITLEMENT_CHECK_UNAVAILABLE | JWT endpoints | Core unreachable, misconfigured, or gave an incomplete answer — see Authentication §3 |
| 500 | INTERNAL_ERROR | any endpoint | Unhandled server error |
Rate limiting#
Applies only to X-API-Key-authenticated endpoints (/register, /recognize, /registrations*). /verify and /liveness are unauthenticated and not rate-limited. JWT and admin endpoints are not rate-limited either.
- Limit is per API key, not per client or gallery — a client with two keys has two independent limits.
- Default:
100requests per60-second sliding window (DEFAULT_RATE_LIMIT/RATE_LIMIT_WINDOW_SEC). Individual keys can be issued with a differentrate_limitat creation time (POST /galleries/{gallery_id}/api-keys). - The window is in-memory per API process — it resets on deploy/restart, and isn't shared across multiple
api-servicereplicas if you ever run more than one. - Exceeding it returns
429 RATE_LIMIT_EXCEEDEDwith a message naming the exact limit and window. There's currently noRetry-Afterheader or remaining-quota header on responses — the limit and window are only discoverable from the error message or this document.
Getting Started#
Account · Galleries · Keys
This covers account-level concepts the API Reference above assumes you already know. For auth headers, error codes, and rate-limit mechanics, see that section — this one is about how an account, its galleries, and its keys relate to each other.
How to get an account#
- Sign up or log in. Dashboard access requires a real account (email/Google login).
- Activate with a $0 checkout. Before you can use the dashboard, you'll complete a Stripe checkout for $0 — this puts a card on file for abuse prevention only. No charge occurs, and nothing is billed later unless you actively choose to upgrade to a paid plan yourself.
- Land in Quick Start. Your first visit to the dashboard creates a real API key (and a gallery to go with it) and shows you a working example to test against immediately — no separate setup step required.
Accounts, galleries, and keys#
- One account can have multiple galleries.
- Each gallery has its own API key(s) — a gallery can have more than one key (e.g. separate keys for production and testing), each with its own rate limit.
- An API key resolves entirely server-side to a specific
client_id+gallery_id+ rate limit. You never specify a gallery or a recognition model in a request — the key you send determines all of it. (This is the same rule the API Reference states from the auth side.) - Keys are created and managed through the JWT-authenticated dashboard/management endpoints (
/galleries,/api-keys) — they're never self-issued by a caller, and the full key value is only ever shown once, at creation.
Dashboard access, restated plainly#
To be unambiguous: using the dashboard requires (1) a real authenticated account and (2) a completed $0 Stripe checkout. That's it — there's no recurring charge, and none will occur, unless you go into billing and change your plan yourself.
Models#
Face Recognition · Liveness · Latency
This applies to both Cloud API and Self-Hosted deployments — the models themselves are identical between the two (self-hosted uses the same weights, gated behind a license check rather than a different model). Nothing about model behavior differs by deployment mode today; the only self-hosted-specific topic is deployment itself, covered separately in Self-Hosted.
Face recognition — Malma 1 and Malma 2#
Two recognition models are available, selected per gallery via model_index at gallery-creation time (see Getting Started — the gallery's API key determines which one a given request uses, you don't choose per-request):
- Malma 1 (
model_index: 0) — optimized for speed at standard accuracy. The default choice for most galleries. - Malma 2 (
model_index: 1) — trades some latency for higher accuracy.
Both take a submitted face image, extract an embedding, and either store it (/register) or compare it against a gallery (/recognize) or against a second image directly (/verify).
Detailed accuracy benchmarks for Malma 1 and Malma 2 will be published here soon.
Supported input formats: JPEG, PNG, WebP. Anything else is rejected with INVALID_IMAGE_FORMAT (see Error reference).
Liveness#
/liveness runs a single combined anti-spoofing check per submitted image — internally it's an ensemble of two underlying models whose scores are summed before a single live/spoof decision is made, so from the API's point of view this is one check, not a customer-selectable choice the way Malma 1/Malma 2 are. (The request body currently accepts a model_index field, but it's reserved for future use — both underlying models always run regardless of what's passed today.)
What can be said confidently, and matters most to a customer evaluating fit:
Latency#
Cloud API: latency benchmarks for /verify, /recognize, /register, and /liveness will be published here soon.
Self-hosted: latency depends on your own infrastructure (GPU vs CPU, hardware generation, network placement) and can't be given as a fixed number here. We can advise on hardware sizing directly during onboarding.
Plans & Rate Limits#
Free · Pro · Custom
This section maps plan tiers to their actual rate-limit values. For how rate limiting works mechanically (per-key sliding window, reset behavior, response shape), see Rate limiting above — this is only about which number applies to which plan.
Plan limits#
| Plan | Rate limit | Notes |
|---|---|---|
| Free | 100 requests/day | No card required |
| Pro | 10,000 requests/day | Coming soon |
| Custom | Negotiated | Contact sales |
What doesn't change with plan#
/verifyand/livenessare unauthenticated and unlimited on every plan. Neither endpoint uses a gallery or API key at all today, so there's nothing plan-specific to apply a limit to. If you're comparing plans, don't assume these two are gated by tier — they aren't gated at all right now.- Rate limiting only ever applies to the gallery-scoped, API-key-authenticated endpoints (
/register,/recognize,/registrations*) — see Error reference for the full list.
Self-Hosted#
Deployment
Deployment#
Self-hosted deployment is available. It runs the same models as Cloud API — Malma 1, Malma 2, and the liveness check — as documented in Models above; nothing about model behavior changes by deployment mode. Weights are license-gated for self-hosted builds rather than swapped for different models.
Supported platforms#
- Linux (x86_64) — the primary, fully tested target. Any distro with Docker Engine 24+ and the Docker Compose v2 plugin. This is what Caracal's own hosted infrastructure runs on.
- macOS (Intel or Apple Silicon) — via Docker Desktop. Confirmed working end-to-end. Depending on your specific release version, Apple Silicon may run the images under Docker Desktop's x86_64 emulation rather than a native ARM64 build — check your release's notes on GitHub Releases (release list only — no repository access is needed or granted).
- Windows — not actively tested. Docker Desktop with WSL2 should work in principle since everything runs in Linux containers, but treat it as unverified.
You'll also need roughly 10GB of free disk for the six service images, and a machine that can run CPU-based ONNX inference at a reasonable speed — no GPU is required.
The steps#
- Get a license. Request a trial via the trial form above or contact us for production. Approval emails you a secure download link — the zip contains everything: your license, a pinned
docker-compose.yml,init.sql, and an.env.example. No separate download, no GitHub access of any kind. - Configure your license.
Editunzip caracal-self-hosted-license.zip -d license-package cd license-package cp .env.example .env.envand paste the contents oflicense.jsonintoFACE_SYSTEM_LICENSE_KEY(compact JSON or base64 — either works). Leaving it blank makesdocker compose uprefuse to start with a clear error naming the missing variable — every service fails closed rather than running unlicensed. - Pull and start it.
All six images are public on GHCR — no registry login needed. A valid license is what actually gates running them, not registry access.docker compose pull docker compose up -d - Verify (optional). Every service already checks its own license at startup and refuses to run without one, so this step just confirms it independently:
Exit codedocker run --rm -e FACE_SYSTEM_LICENSE_KEY \ ghcr.io/nora-alshareef/face-system/api-service:<VERSION> \ python -m licensing.check_license \ --license-key-env FACE_SYSTEM_LICENSE_KEY \ --product "Caracal Self-Hosted"0means the license is valid. Then browsehttp://localhost:8000/docsfor the live, interactive API.