TruthScan Capture API
Mint a phone capture link, receive a signed photo, and read the result with your API key.
All endpoints are on https://truthscan.com. Send the end user to captureLink. Your backend never receives the JPEG.
Authentication
Create a secret key in your account.
Send it on every call as the x-api-key header or as Authorization: Bearer. The key must belong to an organization with API access. The capture link opened on the phone does not use this key.
Replace YOUR_API_KEY with your secret key.
Create a session
Returns a capture link. inviteeEmail is required. expiresInSeconds defaults to 15 minutes and cannot exceed 7 days. maxCaptures defaults to 20 and cannot exceed 20. webhookUrl is optional. When set, it must be https and must resolve to a public address. webhookSecret is returned only in this response.
POST /api/capture/session/createSet sendEmail to true to have TruthScan email the link. Then subject and message are required. Put {link} in the message where the capture URL should appear. emailSent is true only when that email went out. Otherwise send captureLink yourself.
Store webhookSecret. It is the HMAC key for capture.completed.
Example request
curl -X POST https://truthscan.com/api/capture/session/create \
-H 'Content-Type: application/json' \
-H 'x-api-key: YOUR_API_KEY' \
-d '{
"inviteeEmail": "person@example.com",
"expiresInSeconds": 900,
"maxCaptures": 1,
"webhookUrl": "https://example.com/hooks/capture"
}'Example response
{
"sessionId": "11111111-1111-4111-8111-111111111111",
"captureToken": "…",
"captureUrl": "/capture/11111111-1111-4111-8111-111111111111?token=…",
"captureLink": "https://truthscan.com/capture/11111111-1111-4111-8111-111111111111?token=…",
"expiresAt": "2026-10-07T16:00:00.000Z",
"expiresInSeconds": 900,
"maxCaptures": 1,
"webhookSecret": "…",
"emailSent": false
}Read results
Poll the session until capturesUsed increases, or wait for the webhook. Download URLs expire after one hour.
Session status
Expiry, captures used, and the webhook URL you registered.
GET /api/capture/sessions/{sessionId}Example request
curl https://truthscan.com/api/capture/sessions/SESSION_ID \
-H 'x-api-key: YOUR_API_KEY'Example response
{
"sessionId": "11111111-1111-4111-8111-111111111111",
"expiresAt": "2026-10-07T16:00:00.000Z",
"maxCaptures": 1,
"capturesUsed": 1,
"inviteeEmail": "person@example.com",
"webhookUrl": "https://example.com/hooks/capture",
"createdAt": "2026-10-07T15:45:00.000Z"
}List photos
Photos for one session, newest first. Query page and limit (max 50). Each item is a summary plus downloadUrl. The list location only includes latitude, longitude, and altitude. Fetch the asset for accuracy, heading, and speed.
GET /api/capture/sessions/{sessionId}/assetsExample request
curl 'https://truthscan.com/api/capture/sessions/SESSION_ID/assets?page=1&limit=20' \
-H 'x-api-key: YOUR_API_KEY'Example response
{
"assets": [
{
"id": "22222222-2222-4222-8222-222222222222",
"verificationState": "trusted",
"trustTier": "standard",
"risk": { "score": 0.95, "flags": ["geolocation_low_accuracy"] },
"capturedAtUtc": "2026-10-07T15:50:00.000Z",
"submittedAtUtc": "2026-10-07T15:50:01.200Z",
"signedAtUtc": "2026-10-07T15:50:03.000Z",
"captureToSubmitMs": 1200,
"geolocation": {
"latitude": 37.7749,
"longitude": -122.4194,
"altitude": 15,
"altitudeAccuracyMeters": null,
"accuracyMeters": null,
"headingDegrees": null,
"speedMetersPerSecond": null
},
"deviceModel": "iPhone",
"devicePlatform": "iOS",
"inviteeEmail": "person@example.com",
"thumbnailUrl": "https://…",
"downloadUrl": "https://…"
}
],
"pagination": {
"page": 1,
"totalPages": 1,
"total": 1,
"hasPreviousPage": false,
"hasNextPage": false
}
}Get one photo
The same record as the capture detail page, plus assetUrl for the signed JPEG. The sample keeps the fields you branch on. device also includes screen, camera list, GPU, and input. sensors.geolocation repeats the location object.
GET /api/capture/assets/{assetId}Example request
curl https://truthscan.com/api/capture/assets/ASSET_ID \
-H 'x-api-key: YOUR_API_KEY'Example response
{
"id": "22222222-2222-4222-8222-222222222222",
"sessionId": "11111111-1111-4111-8111-111111111111",
"verificationState": "trusted",
"trustTier": "standard",
"risk": { "score": 0.95, "flags": ["geolocation_low_accuracy"] },
"inviteeEmail": "person@example.com",
"mimeType": "image/jpeg",
"assetUrl": "https://…",
"thumbnailUrl": null,
"capturedAtUtc": "2026-10-07T15:50:00.000Z",
"submittedAtUtc": "2026-10-07T15:50:01.200Z",
"serverReceivedAtUtc": "2026-10-07T15:50:02.000Z",
"signedAtUtc": "2026-10-07T15:50:03.000Z",
"createdAtUtc": "2026-10-07T15:50:03.100Z",
"captureToSubmitMs": 1200,
"timing": {
"capturedAtUtc": "2026-10-07T15:50:00.000Z",
"submittedAtUtc": "2026-10-07T15:50:01.200Z",
"serverReceivedAtUtc": "2026-10-07T15:50:02.000Z",
"signedAtUtc": "2026-10-07T15:50:03.000Z",
"captureToSubmitMs": 1200
},
"geolocation": {
"latitude": 37.7749,
"longitude": -122.4194,
"altitude": 15,
"altitudeAccuracyMeters": 10,
"accuracyMeters": 12,
"headingDegrees": null,
"speedMetersPerSecond": null
},
"deviceModel": "iPhone",
"devicePlatform": "iOS",
"device": {
"browserName": "Mobile Safari",
"platform": "iOS",
"timezone": "America/Los_Angeles",
"screenWidth": 390,
"screenHeight": 844,
"camera": { "width": 1920, "height": 1080, "facingMode": "environment" },
"connection": { "effectiveType": "4g", "downlinkMbps": 10, "rttMs": 50 },
"network": {
"publicIp": "203.0.113.10",
"country": "US",
"city": "San Francisco"
}
},
"sensors": {
"gyroVariance": 0.02,
"accelVariance": 0.11,
"motionSampleCount": 24,
"permissionDenied": {
"camera": false,
"geolocation": false,
"motion": false
}
}
}Result fields
verificationState, trustTier, and risk.flags are the values to branch on. assetUrl and downloadUrl expire after one hour.
| Field | Meaning |
|---|---|
| verificationState | trusted, valid_untrusted, invalid, or none |
| trustTier | standard or high |
| risk.score | 0 to 1. Starts at 1. Each sensor flag lowers it. |
| risk.flags | camera_permission_denied, geolocation_permission_denied, geolocation_missing, geolocation_low_accuracy, motion_permission_denied, motion_samples_missing, gyro_missing, zero_gyro_variance, accel_missing |
| geolocation | latitude, longitude, altitude, altitudeAccuracyMeters, accuracyMeters, headingDegrees, speedMetersPerSecond |
| device | Browser, platform, user agent, timezone, screen, camera, connection (type, downlink, RTT), and network (public IP, ASN, organization, country, city, region) |
| sensors | motionSampleCount, gyroVariance, accelVariance, and permissionDenied for camera, geolocation, and motion |
| timing | Shutter, submit, server received, and signed times, plus captureToSubmitMs |
Webhook
After a trusted capture, TruthScan POSTs capture.completed to webhookUrl. Redirects are not followed. The body does not include precise location. Fetch the asset for the full record.
Read X-TruthScan-Signature. It is t=<unix seconds>,v1=<hex>. Compute HMAC-SHA256 over t + "." + the raw body using webhookSecret, and reject timestamps more than five minutes off.
Example body
{
"id": "…",
"type": "capture.completed",
"data": {
"sessionId": "11111111-1111-4111-8111-111111111111",
"assetId": "22222222-2222-4222-8222-222222222222",
"verificationState": "trusted",
"trustTier": "standard",
"capturedAtUtc": "2026-10-07T15:50:00.000Z"
}
}Rate limits
Limits are shared across instances. A 429 includes Retry-After in seconds.
Limits
Each call counts as one request against both the IP and the organization.
| Path | Bucket | Limit |
|---|---|---|
| Create and read | IP | 600 / minute |
| Create and read | Organization | 2,000 / minute |
| Phone capture | IP | 60 / minute |
| Phone capture | Organization | 2,000 / minute |
Create and read covers POST /api/capture/session/create plus the three GET result routes. Phone capture covers nonce, upload-url, and sign. The phone does not send your API key.
Throttled response
Wait for Retry-After, then send the request again.
HTTP/1.1 429 Too Many Requests
Retry-After: 12
Content-Type: application/json
{
"error": "Rate limit exceeded",
"code": "RATE_LIMIT_EXCEEDED",
"retryAfter": 12
}What to read
- Retry-After: seconds until the window resets
- retryAfter: the same value in the JSON body
Best practices
- Poll session status instead of listing assets on a tight loop.
- Prefer the webhook when you can receive HTTPS callbacks.
Errors
Failed calls return JSON with error and code.
401 and 403 mean the key cannot be used. 404 means the session or asset is not in your organization.
| Code | Meaning |
|---|---|
| MISSING_API_KEY | No x-api-key or Bearer token |
| INVALID_API_KEY | Key is unknown, inactive, or has no organization |
| ORG_NO_API_ACCESS | Organization API access is off |
| INVITEE_EMAIL_REQUIRED | inviteeEmail was missing |
| INVITEE_EMAIL_INVALID | inviteeEmail is not a valid address |
| INVITE_MESSAGE_REQUIRED | sendEmail is true and message is missing or too long |
| INVITE_SUBJECT_REQUIRED | sendEmail is true and subject is missing |
| WEBHOOK_URL_REJECTED | webhookUrl is not public https |
| SESSION_NOT_FOUND | No session with that id in your organization |
| ASSET_NOT_FOUND | No asset with that id in your organization |
| RATE_LIMIT_EXCEEDED | Too many requests in the current window |
Frequently asked questions
How the Capture API fits into your backend.