Sessions API
A Session is one inspection. Create one, mint a field-user invite, capture evidence, end it. Everything else hangs off this resource.
The Session object
{
"id": "0c8f4d2e-1a3b-4c5d-9e7f-1234567890ab",
"status": "open",
"operator_email": "ops@yourco.com",
"scheduled_for": "2026-05-23T10:00:00Z",
"started_at": "2026-05-23T10:00:14Z",
"ended_at": null,
"notes": "Vehicle damage — claim CL-2026-0042",
"locale": "fr",
"consent_state": { "camera": "granted", "gps": "granted" },
"campaign": null,
"created_at": "2026-05-21T14:21:00Z",
"updated_at": "2026-05-23T10:00:14Z"
} | Status | Meaning |
|---|---|
created | Session row exists; nobody has joined yet. |
open | Field user has joined; session is live. |
recording | Recording in progress (optional, operator-triggered). |
closed | Session ended. Chain head anchored at TSA; reports available. |
expired | Scheduled session that nobody joined within the TTL. |
Create a session
POST /api/v1/public/sessions — scope sessions:write
curl -X POST https://app.nexbasira.com/api/v1/public/sessions \
-H "Authorization: Bearer nb_sec_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"notes": "Vehicle damage — claim CL-2026-0042",
"scheduled_for": "2026-05-23T10:00:00Z",
"locale": "fr"
}' Returns the freshly-created Session object (HTTP 201).
Body fields
| Field | Type | Required | Notes |
|---|---|---|---|
notes | string | no | Visible to the operator. Shown in invite emails. |
scheduled_for | ISO 8601 | no | Future date triggers reminder emails 24h + 1h before. Omit for "start now". |
locale | string | no | One of the 14 supported locales. Drives the SPA + PDF report language. Defaults to the org's preference. |
campaign | UUID | no | Optional FK to a Campaign for batched reporting. |
List sessions
GET /api/v1/public/sessions — scope sessions:read
curl https://app.nexbasira.com/api/v1/public/sessions?limit=25 \
-H "Authorization: Bearer nb_sec_..." Cursor-paginated. Pass cursor from the previous response's next_cursor to page.
{
"data": [{ /* Session, Session, ... */ }],
"has_more": true,
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAy..."
} Retrieve a session
GET /api/v1/public/sessions/{session_id} — scope sessions:read
End a session
POST /api/v1/public/sessions/{session_id}/end — scope sessions:write
Closes the session, triggers the chain-head anchor at all configured TSAs, and kicks off recording post-processing if a recording was running. Idempotent — calling on an already-closed session returns the closed Session object without re-anchoring.
Mint a field-user invite
POST /api/v1/public/sessions/{session_id}/participants — scope participants:write
curl -X POST https://app.nexbasira.com/api/v1/public/sessions/0c8f.../participants \
-H "Authorization: Bearer nb_sec_..." \
-H "Content-Type: application/json" \
-d '{
"recipient_first_name": "Alex",
"recipient_last_name": "Garcia",
"recipient_email": "alex@policyholder.com",
"send_email": true,
"ttl_minutes": 1440
}' {
"id": "i-1",
"session": "0c8f...",
"role": "field",
"expires_at": "2026-05-24T10:00:00Z",
"recipient_first_name": "Alex",
"recipient_last_name": "Garcia",
"recipient_email": "alex@policyholder.com",
"recipient_phone": "",
"join_url": "https://app.nexbasira.com/join/0c8f.../?t=tok_PLAINTEXT_ONCE",
"otp_code": "487192",
"otp_required": true,
"otp_expires_at": "2026-05-23T10:10:00Z",
"created_at": "2026-05-23T10:00:00Z"
}
The join_url and otp_code are
shown exactly once in the response. Tokens are IP/UA-pinned,
single-use, and time-boxed.
Two-factor join (OTP)
When you supply at least one of recipient_email or
recipient_phone, the platform auto-mints a 6-digit numeric OTP
and sends it on the matching channel(s) in a separate message
from the join URL — defence-in-depth so a forwarded email or SMS doesn't
leak both factors at once. The plain code is also returned in the response
(otp_code) so you can re-share it manually if delivery fails.
Field-side, the SPA pages the OTP prompt on first redemption. Submit the
code via the X-Join-OTP header on a retry of GET
/v1/sessions/{id}/join/{token} — the header (not URL) keeps
the code out of browser history and access logs.
OTP rules:
- 6-digit numeric, hashed with SHA-256 + pepper at rest.
- 10-minute TTL from issue.
- 5 wrong attempts locks the invite (HTTP 423) — operator must reissue.
- Verified once at first redemption; re-redemption from the same IP / UA pair skips the gate (the token is already pinned).
-
Face-to-face URL handoff (no
recipient_email+ norecipient_phone) skips OTP minting — the URL alone is the auth factor. Use this for in-person handoffs only.
Response codes on GET /v1/sessions/{id}/join/{token}:
| Status | Body | Meaning |
|---|---|---|
| 200 | {field_session_token, livekit, ...} | OTP passed (or not required); field session is live. |
| 401 | {detail:"otp_required", channels:[...], channel_hint_email, channel_hint_phone} | SPA should render the OTP entry form. |
| 401 | {detail:"otp_invalid", attempts_remaining} | Wrong code; show remaining attempts. |
| 401 | {detail:"otp_expired"} | 10-min window elapsed; operator must reissue. |
| 423 | {detail:"otp_locked"} | 5 wrong attempts; invite is dead until reissue. |
Common errors
Status Code When 402 billing.subscription_past_dueOrg's Stripe subscription is past due. 402 billing.free_plan_minutes_exhaustedFree / Pilot tier has used its 5 inspections. 403 permission_deniedCredential lacks sessions:write scope. 409 session.already_endedTrying to end a session that's already closed (rare — `end` is idempotent normally). 429 rate_limited60-rpm per-credential or 600-rpm per-org throttle hit. See X-RateLimit-Reset header.
See Errors + rate limits for the full
error envelope shape and retry guidance.