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).
How it works
There are four primitives. Each depends on the previous:
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
| Type | What it verifies | Identity requirement |
|---|---|---|
GEO | Device is within maxDistance metres of the event location at submission time | None - uses coordinates submitted at challenge time |
FACE | Biometric face match against the identity's enrolled photos. Confidence score returned; pass threshold set on your project. | images array, indexed (async after identity creation) |
PIN | Numeric PIN. Hashed comparison - raw PIN never stored. | pin set on identity |
PASSWORD | Free-text password. | password set on identity |
SMS | One-time passcode sent automatically to the identity's mobile number. Submit the received code to validate. | cellNational set on identity |
EMAIL | One-time passcode sent automatically to the identity's email address. Submit the received code to validate. | email set on identity |
CALL | Automated outbound call reads 4 NATO phonetic words. Subject reads them back; speech-to-text validates. | cellNational set on identity |
VIDEO | Live video verification: face match combined with spoken word challenge. Both must pass. | images array, indexed |
ENROL | Captures 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.
| Target | Fields |
|---|---|
| Webhook | webhook.url, webhook.secret |
| AWS EventBridge | aws.roleArn, aws.eventBridgeBus |
| AWS SQS | aws.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
| Method | Path | Description |
|---|---|---|
| GET | /configs | List all configs |
| POST | /configs | Create 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:
geolocation.lat+geolocation.lnggeolocation.placeId(Google Maps place ID)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
| Method | Path | Description |
|---|---|---|
| GET | /locations | List all locations. Supports ?cursor and ?limit (max 100) |
| POST | /locations | Create 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:
emailcellNationalpinexternal
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
| Method | Path | Description |
|---|---|---|
| GET | /identities | List all identities. Supports ?cursor and ?limit (max 100) |
| POST | /identities | Create 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}/faces | Remove 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
| Method | Path | Description |
|---|---|---|
| GET | /events | List all events. Supports ?cursor and ?limit (max 100) |
| POST | /events | Create 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
| Status | Meaning |
|---|---|
| next | Challenge accepted, more remain |
| complete | All challenges passed |
| failed | A challenge failed - stop |
| pending | Async challenge in progress - poll GET |
Submitted inputs (pin, password, image bytes, audio) are never echoed in the response.
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /events/{eventId}/sessions | None | Mint a new session (open events only) |
| GET | /events/{eventId}/sessions/{sessionId} | None | Fetch session state |
| POST | /events/{eventId}/sessions/{sessionId} | None | Submit a challenge |
| GET | /events/{eventId}/sessions/{sessionId}/video/challenge | None | Fetch 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
| Challenge | Identity must have | Notes |
|---|---|---|
GEO | Nothing | Uses device location submitted at challenge time |
FACE | images (indexed) | Indexing is async - allow time after identity creation |
PIN | pin | numeric |
PASSWORD | password | string |
SMS | cell | OTP sent to this number |
EMAIL | email | OTP sent to this address |
CALL | cell | Outbound call, NATO word read-back |
VIDEO | images (indexed) | Face match + spoken word challenge. Both must pass. |
ENROL | Nothing | Open 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" }
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.
| Target | Fields |
|---|---|
| EventBridge | aws.roleArn + aws.eventBridgeBus |
| SQS | aws.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
| Config | Finite? | Why |
|---|---|---|
| Window + identities | Yes | Known who, known when - total is calculable |
| Window only, no identities | No | Known when, but unknown who |
| Identities only, no window | No | Known who, but any time - runs forever |
| Location only | No | Known where, but unknown who and when |
| Neither | No | Open 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"
}
| Status | Error | Meaning |
|---|---|---|
400 | invalid_request | Missing or invalid fields |
401 | invalid_project / invalid_token | Auth failed |
403 | - | API key missing or invalid |
404 | not_found | Resource does not exist |
409 | conflict | Uniqueness violation (email, cell, pin, external) |
500 | server_error | Unexpected error |