CaracalCaracal

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.

By calling the Caracal API, you agree to our Terms of Use, including the biometric-consent obligations in Section 3 — you are responsible for obtaining consent from each end user before submitting their data.

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.

StatuscodeWhereMeaning
400INVALID_BASE64any image-accepting endpointimage_b64 isn't valid base64
400INVALID_IMAGE_FORMATany image-accepting endpointDecoded image isn't JPEG/PNG/WebP
400INVALID_MODELendpoints taking model_indexUnknown model index
400INVALID_PERSON_ID/register, registration lookupsperson_id isn't a valid UUID
400INVALID_STATUS/admin/clients/{id} (PATCH)Status must be active or suspended
401MISSING_API_KEYAPI-key endpointsNo X-API-Key header sent
401INVALID_API_KEYAPI-key endpointsKey doesn't exist or was revoked
401MISSING_TOKENJWT endpointsNo Authorization: Bearer header sent
401TOKEN_EXPIREDJWT endpointsToken's exp has passed
401INVALID_TOKENJWT endpointsSignature/issuer/audience check failed
401INVALID_TOKEN_CLAIMSJWT endpointsToken has no sub claim
403FORBIDDENadmin endpoints, ownership checksWrong admin secret, or the resource doesn't belong to your account
403CLIENT_SUSPENDEDAPI-key and JWT endpointsClient account is suspended
403GALLERY_NOT_FOUNDAPI-key endpointsThe key's gallery was deleted/deactivated
403NOT_ENTITLEDJWT endpointsCore says this account isn't entitled to this product
404NOT_FOUNDmost GET/DELETE by IDClient, gallery, key, or registration doesn't exist
413IMAGE_TOO_LARGEany image-accepting endpointImage exceeds MAX_IMAGE_BYTES
429RATE_LIMIT_EXCEEDEDAPI-key endpointsSee Rate limiting below
502REGISTRATION_FAILED/registerRegistration service call failed
502RECOGNITION_FAILED/recognizeRecognition service call failed
502VERIFY_FAILED/verifyRecognition service (embedding) call failed
502LIVENESS_FAILED/livenessLiveness service returned an error
502LIVENESS_UNAVAILABLE/livenessLiveness service unreachable
503ENTITLEMENT_CHECK_UNAVAILABLEJWT endpointsCore unreachable, misconfigured, or gave an incomplete answer — see Authentication §3
500INTERNAL_ERRORany endpointUnhandled 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: 100 requests per 60-second sliding window (DEFAULT_RATE_LIMIT / RATE_LIMIT_WINDOW_SEC). Individual keys can be issued with a different rate_limit at 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-service replicas if you ever run more than one.
  • Exceeding it returns 429 RATE_LIMIT_EXCEEDED with a message naming the exact limit and window. There's currently no Retry-After header 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#

  1. Sign up or log in. Dashboard access requires a real account (email/Google login).
  2. 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.
  3. 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:

Liveness detection is not deepfake detection. This system is built to catch presentation attacks — things like a photo held up to the camera, or a face replayed on a screen — not to detect AI-generated or deepfake video. If your threat model includes synthetic/deepfake video spoofing specifically, this is not a control for that today.

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#

PlanRate limitNotes
Free100 requests/dayNo card required
Pro10,000 requests/dayComing soon
CustomNegotiatedContact sales

What doesn't change with plan#

  • /verify and /liveness are 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.

Deployment is genuinely simple — three steps once you have a license, no infrastructure team or onboarding call required. This page tells you everything before you request a license, not after.

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#

  1. 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.
  2. Configure your license.
    unzip caracal-self-hosted-license.zip -d license-package
    cd license-package
    cp .env.example .env
    Edit .env and paste the contents of license.json into FACE_SYSTEM_LICENSE_KEY (compact JSON or base64 — either works). Leaving it blank makes docker compose up refuse to start with a clear error naming the missing variable — every service fails closed rather than running unlicensed.
  3. Pull and start it.
    docker compose pull
    docker compose up -d
    All six images are public on GHCR — no registry login needed. A valid license is what actually gates running them, not registry access.
  4. Verify (optional). Every service already checks its own license at startup and refuses to run without one, so this step just confirms it independently:
    docker 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"
    Exit code 0 means the license is valid. Then browse http://localhost:8000/docs for the live, interactive API.
A trial license lets you deploy and verify the whole stack, including the live API docs, but doesn't include the key needed to decrypt the recognition and liveness models — those two workers won't process real requests until you have a production license. This is stated up front deliberately: you should know exactly what a trial does and doesn't unlock before requesting one.
Request a Trial License