# meeeetup Face ID API > External API for reading and managing project persons, detection journal, and browser capture sessions. Endpoints require an API key with appropriate scopes, except the browser capture upload, which uses a capture-session token. ## Docs & source of truth - OpenAPI spec (authoritative — every request/response schema): `/v1/openapi.json` - Interactive docs: `/docs` - This guide: `/v1/llms.txt` - Same guidance as an installable agent skill: `/v1/skill.md` — `curl -o .claude/skills/meeeetup-faceid/SKILL.md /v1/skill.md` ## Data hierarchy ``` organization └── project ← your API key is bound to exactly one ├── scope ← a named grouping: venue, event, gate, campaign │ └── profile ← one person's record inside that scope └── journal entry ← a detection event, references personId ``` - **Organization** — the tenant. Face identity is unique per organization. - **Project** — what an API key addresses. Every read and write is confined to it; you never pass a project id. - **Scope** (`scopeId`) — a grouping inside a project with its own registration form. `GET /v1/scopes` lists them; `POST /v1/scopes` provisions a new one for integrator workflows (e.g. an event system auto-creating its check-in scope); `PATCH /v1/scopes/{scopeId}` updates `name`, `slug` or `formSchema` when the source event changes. Note this is the domain entity, not a permission scope — the two are unrelated despite the shared word. - **Person** (`personId`) — the stable, opaque identity of a human, shared across every scope and project in the organization, and unique to it. This is the id to store. See "Identity model" below. - **Profile** — a person's record within one scope: `name`, `email`, `phone`, `department`, `formData`, plus `status` (`registered`, `unregistered`, `detected-only`). One profile per person per scope, so the same person can be registered in two scopes with different details. `/v1/persons` returns profiles; each carries its own `id` plus the `personId` it belongs to (`null` until a face links them). - **Journal entry** — an immutable detection: `personId`, `detectedAt`, originating `captureKeyId` / `channelId`, and estimated attributes. Which endpoint to reach for: - Same human across scopes → `personId`, via `POST /v1/persons/resolve`. - That human's details in one scope → `GET /v1/scopes/{scopeId}/profiles/{personId}`. - Everyone in the project → `GET /v1/persons`. - When and where someone was seen → `GET /v1/journal`. ## Authentication Two different bearer credentials, for two different callers. **Project API key** — long-lived, secret, server-side only. Used by every endpoint except the browser capture upload: ``` Authorization: Bearer ``` - The key encodes its project; all reads and writes are automatically scoped to that project (and, for global identity, its organization). You never pass a project id yourself. - Verify a key and inspect its granted scopes with `GET /v1/debug` — no scope required. - `401`: missing/invalid key. `403`: the key lacks the scope the endpoint requires (each endpoint's required scope is stated in its description below). **Capture-session token** — short-lived JWT returned by `POST /v1/capture-sessions` (which itself needs the `captureSession:create` scope). Safe to hand to a browser: it is bound to one session, one scope, one origin, a capture cap and an expiry, and it is accepted by `POST /v1/capture-sessions/{sessionId}/captures` only. Never ship the API key to a browser. Revoking an API key immediately closes every capture session it minted, so outstanding browser tokens stop working at once rather than lasting until their expiry. ## Client SDK — optional, capture only You do not have to write the camera side. Two public npm packages capture faces on-device and hand you the selected JPEGs: ``` npm i @meeeetup/camera-web react react-dom # browsers npm i @meeeetup/camera-react-native # iOS / Android ``` They run face detection and pick the best frame per person, then call your sink (`onBatchCapture` / `onCapture`) with base64 JPEG data URLs. They hold no credential and never call this API: posting the images to `POST /v1/capture-sessions/{sessionId}/captures` with the session token is your code. Writing your own client instead is fine — the endpoints below are the whole contract. ## Capture-session results — webhook or inline Each capture returns its result inline in the upload response (`POST /v1/capture-sessions/{sessionId}/captures` returns one `{ captureId, status, … }` per posted image, positionally aligned). You can consume results this way and be done. If you also want an async delivery — useful when the browser closes before all resolutions finish, or when a backend job needs the same events — supply `webhookUrl` when creating the session. It is **optional**: - **Omit `webhookUrl`**: no webhook is attempted; no `webhookSecret` is returned. Only the inline response carries results. - **Include `webhookUrl`** (https, publicly reachable): a `webhookSecret` is returned exactly once, and each processed capture is POSTed to that URL as JSON (schema below). Inline results are still returned. ## Capture-session webhooks Each processed capture is POSTed to `webhookUrl` as JSON: ```json { "type": "capture_session.profile_resolved", "captureId": "cev_…", "sessionId": "cps_…", "scopeId": "scp_…", "status": "matched", "profile": { "…": "present only when status is matched" }, "errorCode": "…only when status is error", "errorMessage": "…only when status is error" } ``` - `status`: `matched`, `no_match`, `no_face`, `error`, `accepted`. - `captureId` matches `results[i].captureId` from the upload response — use it to correlate, and to drop duplicates if a delivery is retried. - Signature header: `Meeeetup-Signature: t=,v1=` where `` is `HMAC-SHA256(webhookSecret, ".")`. Recompute it over the **raw** body before parsing, compare in constant time, and reject stale `t` values. - Respond `2xx`; any other status is recorded as a failed delivery. ## Capture-session captures are identify-only Captures resolve against the session's scope; they never create a person or a scope profile. `no_match` means no profile in this scope matches the face — including a person who exists elsewhere in the project but was never registered in this scope. To enroll a walk-up face, call `POST /v1/scopes/{scopeId}/profiles` from your server with an API key holding `person:create`; the visitor's next capture then resolves `matched`. Enrollment also happens through the scope's registration form or the device ingest path (`POST /capture` with a capture key). Use `POST /v1/persons/resolve` if you need to know whether the face is known anywhere in the organization. ## Conventions - JSON bodies; timestamps are ISO 8601 (UTC). - Errors: `{ "code": string, "error": string }`. Codes: `VALIDATION_ERROR` (400), `UNAUTHORIZED` (401), `FORBIDDEN` (403), `NOT_FOUND` (404), `CONFLICT` (409), `INTERNAL_ERROR` (500). - Offset pagination: query `limit` (1–100, default 50) + `offset`; response `{ data, total, limit, offset }`. - Cursor pagination (journal): query `limit` + `before`; response `{ data, has_more, next_before }`. Resend `next_before` as `before` until `has_more` is false. - **Idempotent creation**: create endpoints that accept an `externalId` field (your own opaque identifier for the source object) return the existing resource with `200` on a retry with the same `(projectId, externalId)` instead of creating a duplicate. First successful create returns `201`. Use this to make integrator sync loops safely retryable. ## Identity model — use personId, never a raw face id - The API never returns a raw biometric face id. `personId` is the only cross-scope handle; store and reuse it. - `personId` is **pairwise**: it is issued per organization, so two organizations receive two different, unlinkable `personId` values for the same human. It is meaningful only against this organization's data and cannot be used to correlate people across organizations. - Do not confuse `personId` with a record's own `id`. A person record's `id` addresses that one profile row (`GET /v1/persons/{id}`); `personId` is the org-wide identity the row belongs to, and is what `/v1/journal` and `/v1/persons/resolve` return. - Resolve a face to a person once via the resolve endpoints, then reuse the id. Send face images as base64 (a `data:image/...;base64,` URL or raw payload). ## Endpoints ### Debug - `GET /v1/debug` — Debug API key — Verifies a key is valid and returns its metadata. No scope required - useful for testing your integration. ### Persons - `GET /v1/persons` — List persons — Returns persons in the project with pagination. Requires `person:read` scope. - `POST /v1/persons` — Create person — Creates a person record. Requires `person:create` scope. - `GET /v1/persons/{id}` — Get person — Returns a single person record by its `id` (not by `personId`, which is the org-wide identity). Requires `person:read` scope. - `PATCH /v1/persons/{id}` — Update person — Updates person info fields, addressed by the record's `id` (not by `personId`). Requires `person:update` scope. - `POST /v1/persons/resolve` — Resolve person identity by image — Resolves the global person identity matching a face image within the key's organization. Requires `person:read` scope. ### Scopes - `GET /v1/scopes` — List project scopes — Lists the scopes owned by the API key's project. Requires `person:read` scope. - `POST /v1/scopes` — Create a scope with a registration form — Creates a new scope with an optional registration form. When `externalId` is supplied, a retry with the same (projectId, externalId) returns the existing scope (200) instead of creating a duplicate (201). Requires `scope:create` scope. - `PATCH /v1/scopes/{scopeId}` — Update a scope and its registration form — Applies a partial update to a scope owned by the API key's project. `formSchema` is overwritten wholesale — pre-GA there is no form versioning, so profile answers keyed to removed or renamed question ids remain in storage and the console renders them as raw JSON. `externalId` is immutable and is rejected with 400; rebind by creating a new scope. Requires `scope:update` scope. ### Profiles - `GET /v1/scopes/{scopeId}/profiles/{personId}` — Get profile by scope and person — Returns a person's profile within a scope. Requires `person:read` scope. - `POST /v1/scopes/{scopeId}/profiles/resolve` — Resolve profile by image within a scope — Resolves the person identity for a face image, then returns that person's profile in the scope. Requires `person:read` scope. - `POST /v1/scopes/{scopeId}/profiles` — Enroll a face into a scope — Indexes a face image and registers that person in the scope, so later capture-session uploads resolve to `matched`. Idempotent: a face already enrolled in this scope returns its existing profile. Requires `person:create` scope. ### Capture Sessions - `POST /v1/capture-sessions` — Create browser capture session — Creates a short-lived browser capture session token for direct image upload. Requires `captureSession:create` scope. - `POST /v1/capture-sessions/{sessionId}/captures` — Upload browser captures — Resolves one or more browser-captured face images against the session's scope and returns a positional result per capture. Identify-only: never creates a person or a scope profile; `no_match` includes faces of people registered in another scope of the same project. Enrollment happens through the scope's registration form, `POST /v1/scopes/{scopeId}/profiles` (API key with `person:create`), or `POST /capture` with a capture key. Authenticated with the `captureSessionToken` from POST /v1/capture-sessions — not an API key, and no API-key scope applies. The token itself pins the org, project and scope, the allowed origin, the capture cap and the expiry. ### Journal - `GET /v1/journal` — Detection journal — Returns face detection events ordered by most recent. Use `before` (the `next_before` from a previous response) to page through results. Use `from`/`to` to bound a time window. Requires `journal:read`. ### Docs - `GET /v1/skill.md` — Installable agent skill for integrating this API — Markdown skill file (YAML frontmatter + integration guide) for coding agents such as Claude Code. Covers credentials, the data model, the capture SDKs and the failure modes that break integrations. Public — no API key required. For exact field names and types, consult `/v1/openapi.json`.