Developer Docs

presence.tools is an API for physical presence verification. It answers one question with a cryptographic audit trail: was this specific person, at this specific place, at this specific time? No app for the subject to install. No frontend to build (unless you want to).

Example app

How it works

There are four primitives. Each depends on the previous:

Config Location Identity Event Sessions

A Config defines the rules - which challenges to run and where to deliver results. A Location is a physical place. An Identity is a known person. An Event brings them together.

For finite events (known identities + time window), presence.tools pre-generates sessions immediately on event creation - one per identity per location combination - and returns them in the response. Distribute those URLs to the relevant people.

For infinite events (no identities, or no window), a single URL is returned at event creation. Each new subject mints their own session on demand by hitting that URL - there are no pre-built sessions.

Either way, when a subject opens their session URL the terminal runs the challenge chain in their browser. When they complete it, you receive the result on your webhook.

Base URL  https://api.presence.tools

Authentication

Tokens are project-scoped. Each project has its own account_id, project_id, and project_secret. Tokens expire after 60 minutes.

POST /auth/token

Exchange credentials for a Bearer token. No auth required.

{
  "grant_type": "client_credentials",
  "account_id": "acc_...",
  "project_id": "proj_...",
  "project_secret": "your-project-secret"
}

Response:

{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 3600
}

POST /auth/refresh

Refresh the current token before it expires. Pass the current Bearer token in the Authorization header. No body required. If the token has already expired, re-authenticate via /auth/token.

Using the token

All authenticated endpoints require both headers:

Authorization: Bearer <access_token>
x-api-key: <project_id>

Configs

A config defines the challenge chain and delivery settings for an event. Create one config and reuse it across many events. Think of it as a template.

{
  "configId": "cfg_...",
  "name": "Standard check-in",
  "challenges": [
    { "type": "GEO", "maxDistance": 50 },
    { "type": "FACE" }
  ],
  "webhook": {
    "url": "https://your-app.com/webhooks/presence",
    "secret": "your-webhook-secret"
  },
  "aws": {
    "roleArn": null,
    "eventBridgeBus": null,
    "sqsQueueUrl": null
  }
}

challenges is an ordered array - executed in sequence. A single failure stops the chain. Defaults to []. A config with no challenges will auto-complete every session immediately.

Challenge types

TypeWhat it verifiesIdentity requirement
GEODevice is within maxDistance metres of the event location at submission timeNone - uses coordinates submitted at challenge time
FACEBiometric face match against the identity's enrolled photos. Confidence score returned; pass threshold set on your project.images array, indexed (async after identity creation)
PINNumeric PIN. Hashed comparison - raw PIN never stored.pin set on identity
PASSWORDFree-text password.password set on identity
SMSOne-time passcode sent automatically to the identity's mobile number. Submit the received code to validate.cellNational set on identity
EMAILOne-time passcode sent automatically to the identity's email address. Submit the received code to validate.email set on identity
CALLAutomated outbound call reads 4 NATO phonetic words. Subject reads them back; speech-to-text validates.cellNational set on identity
VIDEOLive video verification: face match combined with spoken word challenge. Both must pass.images array, indexed
ENROLCaptures form field data from an unknown subject. Always passes - data capture only, not a gate.None - open events only

See Challenge types for full submission details and config options for each type.

IP controls

Configs accept an optional ip block that is evaluated on every session submission. Because the config is resolved at session runtime rather than baked in at event creation, you can tighten or relax these rules at any time and the change takes effect immediately for all active sessions on that config.

"ip": {
  "whitelist": [],   // CIDR or exact IPs — if set, only these may submit
  "blacklist": [],   // CIDR or exact IPs — always rejected
  "blockVpn": false  // reject known VPN, proxy, and datacenter IPs
}

whitelist takes precedence over blockVpn. blacklist always wins. Omit the ip block entirely to apply no IP restrictions.

The config is queried fresh on every session — it is never cached at event creation. PATCH a config mid-event to respond to an active threat without touching the event or regenerating sessions.

Delivery options

Configure one or more delivery targets. Presence fires the full event object on every state change.

TargetFields
Webhookwebhook.url, webhook.secret
AWS EventBridgeaws.roleArn, aws.eventBridgeBus
AWS SQSaws.roleArn, aws.sqsQueueUrl

AWS delivery uses cross-account IAM role assumption - no credentials are stored on the platform. Your IAM role trusts the presence.tools AWS account.

Endpoints

MethodPathDescription
GET/configsList all configs
POST/configsCreate a config
GET/configs/{id}Fetch a single config
PATCH/configs/{id}Update config fields
DELETE/configs/{id}Soft-delete. Retained 30 days then removed.

Locations

A location is a named physical place. Presence enriches it automatically with coordinates, geohash, timezone, and reverse geocode.

{
  "locationId": "loc_...",
  "name": "Warehouse A",
  "external": "site-001",
  "geolocation": {
    "lat": 51.5074,
    "lng": -0.1278,
    "placeId": "ChIJdd...",
    "plusCode": "9C3XGV+...",
    "w3w": null,
    "geohash": "gcpvh0"
  },
  "address": { ... },
  "timezone": "Europe/London"
}

Coordinate input

Supply coordinates one of three ways, in priority order:

  1. geolocation.lat + geolocation.lng
  2. geolocation.placeId (Google Maps place ID)
  3. geolocation.w3w (what3words address)

All paths are enriched with a reverse geocode and timezone automatically. external is your own reference ID for linking back to your internal system.

Endpoints

MethodPathDescription
GET/locationsList all locations. Supports ?cursor and ?limit (max 100)
POST/locationsCreate a location
GET/locations/{id}Fetch a single location
PATCH/locations/{id}Update location fields
DELETE/locations/{id}Soft-delete a location

Identities

An identity is a known subject - an employee, contractor, visitor, or any named individual. Identities are scoped to a project.

{
  "identityId": "idn_...",
  "external": "your-internal-id",
  "name": { "first": "Jane", "last": "Smith" },
  "email": "jane@example.com",
  "cell": "447777666555",
  "pin": "1234",
  "ttl": null,
  "meta": {
    "department": "Engineering",
    "accessLevel": "standard"
  }
}

cellNational is the number as dialled locally - no country code or prefix required. meta is a free-form object for any extra data you want to carry.

ttl is a Unix timestamp controlling when the identity record is deleted. Pass null or omit it to keep the identity indefinitely. Set it to a future Unix timestamp to schedule automatic deletion - useful for temporary access or GDPR retention limits.

Uniqueness

The following fields are unique per project. A 409 is returned on duplicate:

  • email
  • cellNational
  • pin
  • external

Face enrolment

Supply images as an array of HTTPS URLs pointing to photos of the subject. Presence fetches and indexes these asynchronously after the identity is created. Once indexed, the FACE challenge is available for this identity.

Allow time for indexing before using the FACE challenge in a live event. Face enrolment quality feedback is surfaced on the identity record - catch poor enrolment photos at setup time, not during a live verification.

Endpoints

MethodPathDescription
GET/identitiesList all identities. Supports ?cursor and ?limit (max 100)
POST/identitiesCreate an identity
GET/identities/{id}Fetch a single identity
PATCH/identities/{id}Update identity fields
DELETE/identities/{id}Soft-delete an identity
DELETE/identities/{id}/facesRemove all face data. Identity record is preserved. Re-enrol by PUTting with a new images array.

Events

The event is the core unit. It brings together a config (rules), locations (where), and identities (who), and produces sessions - the actual verification instances.

{
  "eventId": "evt_...",
  "configId": "cfg_...",
  "name": "morning-shift-2026-06-11",
  "locations": [
    {
      "locationId": "loc_...",
      "window": {
        "opens_at": "2026-06-11T06:00:00.000Z",
        "closes_at": "2026-06-11T14:00:00.000Z"
      }
    }
  ],
  "identities": ["idn_aaa", "idn_bbb"],
  "progress": {
    "total": 2,
    "completed": 0,
    "success": 0,
    "failure": 0
  },
  "sessions": [
    { "sessionId": "a3f9kz", "identityId": "idn_aaa", "url": "https://app.presence.tools/c/a3f9kz" },
    { "sessionId": "b4g0lm", "identityId": "idn_bbb", "url": "https://app.presence.tools/c/b4g0lm" }
  ],
  "ttl": null
}

progress and log are server-managed - never send them in a request. ttl is a Unix timestamp; defaults to now + project.eventRetentionDays.

Location options

Passing locations narrows the GEO challenge - the subject must be within range of at least one. The result records which location they were nearest to and how far away. With no locations, GEO auto-passes and still captures proximity to the nearest known location in the project.

"locations": ["loc_xxxxx", "loc_yyyyy"]

Add a window to make the requirement strict - the subject must be at that location and within the open/close window for GEO to pass. This also makes the event finite: sessions are pre-generated and event.completed fires once everyone has verified:

"locations": [
  {
    "locationId": "loc_xxxxx",
    "window": {
      "opens_at": "2026-06-11T18:00:00.000Z",
      "closes_at": "2026-06-12T18:00:00.000Z"
    }
  }
]

Set locationId to null with a window to enforce a time constraint without caring about place - GEO passes as long as the session is completed within the window:

"locations": [
  {
    "locationId": null,
    "window": {
      "opens_at": "2026-06-11T18:00:00.000Z",
      "closes_at": "2026-06-12T18:00:00.000Z"
    }
  }
]

If you update locations on an existing event, only sessions where the location window has not yet been responded to will be patched - completed sessions are locked.

Endpoints

MethodPathDescription
GET/eventsList all events. Supports ?cursor and ?limit (max 100)
POST/eventsCreate an event
GET/events/{id}Fetch event with current progress and full log
PUT/events/{id}Update event fields. progress and log cannot be overwritten.
DELETE/events/{id}Soft-delete an event and all associated sessions

Sessions

Sessions are the verification instances generated by an event. All session endpoints sit under /events/{eventId}/.

Session endpoints are unauthenticated by design - they are hit directly by the subject's browser at the terminal URL. No API key or Bearer token needed. Sessions are protected by unguessable IDs and are single-use: once completed or expired they cannot be replayed.

Session object

{
  "sessionId": "a3f9kz",
  "eventType": "CLOSED",
  "eventId": "evt_...",
  "configId": "cfg_...",
  "identityId": "idn_...",
  "challenges": [
    { "type": "GEO", "maxDistance": 50 },
    { "type": "FACE" }
  ],
  "displayName": "Acme Corp",
  "url": "https://app.presence.tools/c/a3f9kz",
  "endpoint": "https://api.presence.tools/events/{eventId}/sessions/a3f9kz",
  "used": false
}

url is the hosted terminal - distribute this to the subject. endpoint is the raw API endpoint for building your own terminal.

Submitting challenges

One endpoint handles all challenge types:

POST /events/{eventId}/sessions/{sessionId}

Single challenge:

{ "type": "PIN", "pin": "1234" }

Multiple challenges in one request (processed in order, stops on first failure):

[
  { "type": "GEO", "lat": 51.5074, "lng": -0.1278 },
  { "type": "PIN", "pin": "1234" }
]

Response status values

StatusMeaning
nextChallenge accepted, more remain
completeAll challenges passed
failedA challenge failed - stop
pendingAsync challenge in progress - poll GET

Submitted inputs (pin, password, image bytes, audio) are never echoed in the response.

Endpoints

MethodPathAuthDescription
POST/events/{eventId}/sessionsNoneMint a new session (open events only)
GET/events/{eventId}/sessions/{sessionId}NoneFetch session state
POST/events/{eventId}/sessions/{sessionId}NoneSubmit a challenge
GET/events/{eventId}/sessions/{sessionId}/video/challengeNoneFetch a VIDEO token before recording

Challenge types

Most challenges require pre-existing data on the identity. A challenge will fail at runtime if the required field is absent - there is no fallback. Design your config and identity creation flow together.

Prerequisites

ChallengeIdentity must haveNotes
GEONothingUses device location submitted at challenge time
FACEimages (indexed)Indexing is async - allow time after identity creation
PINpinnumeric
PASSWORDpasswordstring
SMScellOTP sent to this number
EMAILemailOTP sent to this address
CALLcellOutbound call, NATO word read-back
VIDEOimages (indexed)Face match + spoken word challenge. Both must pass.
ENROLNothingOpen events only - data capture, always passes

GEO

Verifies the device is within maxDistance metres of the event location. Defaults to 50m. If the event has no locations, GEO auto-passes and records the closest known location.

{ "type": "GEO", "lat": 51.5074, "lng": -0.1278 }

FACE

Biometric face match against the identity's indexed photos. A confidence score is returned; the pass threshold is set on your project. image must be a base64-encoded JPEG with a data URI prefix.

{ "type": "FACE", "image": "data:image/jpeg;base64,..." }

PIN

Numeric PIN match. The submitted value is hashed - the raw PIN is never stored.

{ "type": "PIN", "pin": "1234" }

PASSWORD

Free-text password.

{ "type": "PASSWORD", "password": "correct-horse-battery-staple" }

SMS

The API automatically sends the OTP when this challenge becomes active - no separate trigger call needed. Submit the code the identity received to validate.

{ "type": "SMS", "otp": "4829" }

EMAIL

Same as SMS - OTP is dispatched automatically. Submit the code to validate.

{ "type": "EMAIL", "otp": "7351" }

CALL

Automated outbound call. Presence calls the identity's number and reads 4 NATO phonetic words. Speech-to-text validates the read-back. Returns pending - poll GET until resolved.

The NATO words are stamped on the session at creation time as value (e.g. "Bravo-Tango-November-Foxtrot"). Surface these in your terminal so the subject knows what to expect.

{ "type": "CALL" }

VIDEO

Live video verification. Face match combined with a spoken word challenge. Both must pass.

Fetch a single-use token immediately before the user starts recording - not on page load. The token expires after 60 seconds.

GET /events/{eventId}/sessions/{sessionId}/video/challenge
// returns: { token, expiresAt }

{
  "type": "VIDEO",
  "token": "a3f9kz2b",
  "frame": "data:image/jpeg;base64,...",
  "video": "data:video/webm;base64,..."
}

Request { video: true, audio: true } from getUserMedia. If only video is captured without audio, the spoken word challenge cannot be assessed and the challenge will fail.

ENROL

Collects form field data from an unknown subject. Always passes - it is a data capture step, not a gatekeeping step. Used in open events where you don't know the subject in advance.

// Config definition:
{
  "type": "ENROL",
  "fields": [
    { "name": "firstName", "label": "First name", "type": "text",  "required": true },
    { "name": "lastName",  "label": "Last name",  "type": "text",  "required": true },
    { "name": "email",     "label": "Email",      "type": "email", "required": true }
  ]
}

// Submission:
{ "type": "ENROL", "data": { "firstName": "John", "lastName": "Smith", "email": "j@example.com" } }

Webhook delivery

Presence fires the full event object to your system on every state change. Configure a webhook.url on your config. Optionally set a webhook.secret - presence signs the payload with X-Presence-Signature (HMAC-SHA256).

session.completed

Fires on every session completion, regardless of whether the overall event is done. identityId is null for open sessions. data is the full session object.

{
  "type": "SESSION",
  "eventName": "session.completed",
  "sessionId": "a3f9kz",
  "eventId": "evt_...",
  "projectId": "proj_...",
  "identityId": "idn_...",
  "completedAt": "2026-06-11T06:15:00.000Z",
  "data": { ... }
}

event.completed

Fires once when all sessions for a finite event are complete. Only fires when progress.total is set. data is the full event object with recompiled progress.

{
  "type": "EVENT",
  "eventName": "event.completed",
  "eventId": "evt_...",
  "projectId": "proj_...",
  "completedAt": "2026-06-11T14:00:00.000Z",
  "data": { ... }
}

Use type to route the two payloads differently in your handler.

AWS delivery

As an alternative to webhook, configure EventBridge or SQS on your config. Delivery uses cross-account IAM role assumption - no credentials stored.

TargetFields
EventBridgeaws.roleArn + aws.eventBridgeBus
SQSaws.roleArn + aws.sqsQueueUrl

Finite vs infinite events

The structure of your event object determines whether the event has a defined completion state. You do not set a flag - the data itself expresses the intent.

Finite events

Provide both a time window on the location and one or more identities. Presence knows the complete set of expected verifications, pre-builds all sessions at creation, and fires event.completed when every identity has verified.

{
  "locations": [{ "locationId": "loc_...", "window": { "opens_at": "...", "closes_at": "..." } }],
  "identities": ["idn_aaa", "idn_bbb"]
}

Infinite events

When the completion set cannot be determined - no identities, no window, or only one of those - the event runs indefinitely. Sessions mint on demand. No progress.total and no event.completed webhook.

{
  "locations": [{ "locationId": "loc_..." }],
  "identities": []
}

What makes the difference

ConfigFinite?Why
Window + identitiesYesKnown who, known when - total is calculable
Window only, no identitiesNoKnown when, but unknown who
Identities only, no windowNoKnown who, but any time - runs forever
Location onlyNoKnown where, but unknown who and when
NeitherNoOpen to anyone, forever

A location constraint alone never makes an event finite. You don't know who's coming, so you can never know when you're done.

Event recipes

Recipe 1 - Shift check-in

Known workers, fixed window. The event closes when all workers have checked in.

Stop GPS spoofing on clock-in. Prevent credential sharing on shift handover. Use case: construction sites, warehouses, gig platforms, regulated venues.

{
  "configId": "cfg_...",
  "name": "morning-shift-2026-06-11",
  "locations": [
    {
      "locationId": "loc_site-a",
      "window": {
        "opens_at": "2026-06-11T06:00:00.000Z",
        "closes_at": "2026-06-11T09:00:00.000Z"
      }
    }
  ],
  "identities": ["idn_worker-1", "idn_worker-2", "idn_worker-3"]
}

Suggested config challenges: [{ "type": "GEO", "maxDistance": 50 }, { "type": "PIN" }]

Recipe 2 - Multi-site muster

Same workers, multiple locations. Presence tracks each combination independently.

Prove a field visit actually happened - at each point on the route. Use case: site surveys, multi-point inspections, safety musters.

{
  "configId": "cfg_...",
  "name": "site-inspection-2026-06-11",
  "locations": [
    { "locationId": "loc_gate-a", "window": { "opens_at": "2026-06-11T08:00:00.000Z", "closes_at": "2026-06-11T09:00:00.000Z" } },
    { "locationId": "loc_gate-b", "window": { "opens_at": "2026-06-11T09:30:00.000Z", "closes_at": "2026-06-11T10:30:00.000Z" } }
  ],
  "identities": ["idn_inspector-1", "idn_inspector-2"]
}

4 sessions created (2 workers × 2 locations). event.completed fires when all 4 are done.

Recipe 3 - Permanent access

Known identity, no fixed window. The session URL becomes a permanent access token.

Verify your remote contractor was on site, every time. Use case: recurring building access, gym memberships, ongoing contractor access.

{
  "configId": "cfg_...",
  "name": "main-entrance-access",
  "locations": [{ "locationId": "loc_main-entrance" }],
  "identities": ["idn_member-1"]
}

Recipe 4 - Open visitor log

Anyone can walk up and verify. No pre-registered identities.

Use case: event sign-in, drop-in clinics, trade show registration.

{
  "configId": "cfg_enrol-and-geo",
  "name": "visitor-log-june-2026",
  "locations": [
    {
      "locationId": "loc_reception",
      "window": { "opens_at": "2026-06-11T09:00:00.000Z", "closes_at": "2026-06-11T17:00:00.000Z" }
    }
  ],
  "identities": []
}

Display the returned session URL as a QR code at reception. Each visitor calls POST /events/{eventId}/sessions to mint their own session.

Recipe 5 - High-assurance identity check

Face match combined with video challenge. For regulated contexts where PIN and SMS are insufficient.

Know your compliance check was done in person. Use case: financial services onboarding, controlled substance dispensing, high-security access.

{
  "configId": "cfg_high-assurance",
  "name": "onboarding-verification-jones",
  "locations": [],
  "identities": ["idn_applicant-jones"]
}

// Config:
{
  "name": "High assurance",
  "challenges": [{ "type": "FACE" }, { "type": "VIDEO" }]
}

Recipe 6 - Remote dwell check

Verify a remote worker is at their declared location during work hours. Create a new event each day.

Verify your remote contractor was on site. Stop GPS spoofing from home workers. Use case: remote working compliance, field service verification, duty of care.

{
  "configId": "cfg_remote-dwell",
  "name": "remote-check-wade-2026-06-11",
  "locations": [
    {
      "locationId": "loc_wades-home-office",
      "window": { "opens_at": "2026-06-11T09:00:00.000Z", "closes_at": "2026-06-11T17:00:00.000Z" }
    }
  ],
  "identities": ["idn_wade"]
}

// Config:
{
  "name": "Remote dwell",
  "challenges": [{ "type": "GEO", "maxDistance": 100 }, { "type": "FACE" }]
}

100m maxDistance accounts for reduced GPS precision in home working environments.

Errors

All endpoints return errors in a consistent shape:

{
  "error": "invalid_request",
  "error_description": "Missing required fields: configId"
}
StatusErrorMeaning
400invalid_requestMissing or invalid fields
401invalid_project / invalid_tokenAuth failed
403-API key missing or invalid
404not_foundResource does not exist
409conflictUniqueness violation (email, cell, pin, external)
500server_errorUnexpected error