API Reference

Create verifications and retrieve results programmatically using the REST API.

i
The API provides the same capabilities as the dashboard. Everything you can do in the web interface, you can automate via these endpoints.

Base URL

All API requests are made to:

https://verify-api.ad-hoc.app

Authentication

All endpoints except /auth require a Bearer token in the Authorization header. Obtain a token by calling the auth endpoint with your account credentials.

POST /auth

Exchange your email and password for an access token.

Request
curl -X POST https://verify-api.ad-hoc.app/auth \ -H "Content-Type: application/json" \ -d '{ "email": "[email protected]", "password": "your-password" }'
Response 200
{ "access_token": "eyJhbGciOiJIUzI1NiIs...", "refresh_token": "v1.MR...", "expires_in": 3600, "token_type": "Bearer" }
access_token
JWT token to use in the Authorization: Bearer header
refresh_token
Token to obtain a new access token without re-authenticating
expires_in
Token lifetime in seconds (default: 3600 = 1 hour)
!
Tokens expire after 1 hour. When you receive a 401 response on any endpoint, re-authenticate to get a fresh token. Rate limit: 5 attempts per minute.

Create Verification

POST /verification

Create a new verification request. Returns a unique URL to send to the person being verified.

Headers
Authorization: Bearer <access_token> Content-Type: application/json
Request Body
template_id optional
UUID of a saved template to use as defaults
custom_message optional
Message shown to the end user on the consent screen
webhook_url optional
HTTPS URL where results are POSTed on completion
webhook_headers optional
Custom headers sent with the webhook (e.g., API keys)
requested_fields optional
Array of PII field keys to collect (see table below)
encryption_key_mode optional
"passphrase", "custom", or "none". Required when the request collects personal data and the template does not set one. The legacy "shared" mode is no longer accepted for new verifications.
encryption_public_key optional
The ID of one of your saved encryption keys (shown on the Encryption Keys page). For "custom" this can also be a PEM-encoded RSA public key. For "passphrase" it must be the ID of a passphrase key created in the dashboard. Required unless the template supplies one.
phone_verification optional
Enable SMS OTP phone verification (default: false). Makes the request a Full verification.
address_verification optional
Check that the license address is deliverable and that the name on the license is on record at that address (default: false). Adds the address_verification indicators to the result (see Indicators). Makes the request a Full verification.
location_consistency optional
Compare the end user's network location with the address on their license (default: false). Adds the location_consistency indicator to the result. Makes the request at least a Basic verification.
include_license_photo optional
Include cropped driver's license face photo in results (default: false). Makes the request a Full verification.
external_id optional
Your own reference identifier for this verification (max 64 characters). Returned as-is in the webhook payload under external_id so you can correlate results with your own system. Use values like UUIDs, order numbers, or hashes — e.g. "PO12345", "job-candidate-1". Do not include names or other personally identifiable information. See the template notes below.
notification_emails optional
Up to 3 comma-separated email addresses that each receive a readable summary of the results when the verification completes, in addition to the webhook. Falls back to the template's addresses when omitted.
expires_in_days optional
How long the link stays valid, as a whole number from 1 to 90 (default: 14). An unfinished link holds one of your verification slots until it completes, expires, or is canceled, so shorter lifetimes free unused slots sooner.

Templates, Defaults & Gotchas

All fields are optional. If template_id is provided, its settings are used as defaults — any field you explicitly provide overrides the template value, and fields you leave out inherit it. A few things behave differently from the dashboard:

  • Template Request Rules are not enforced by the API. The Request Rules on a template (for example "require the custom message to be changed" or "require a Reference ID") are checked only by the dashboard's New Request form. An API call that references the template can omit external_id or send the template's message unchanged, and it will still succeed. If your integration depends on those values, validate them in your own code before calling the API.
  • Templates are a starting point, not a link. The verification copies the template's values when it is created. Editing the template later never changes verifications that already exist.
  • external_id is never inherited. Templates supply defaults for most settings, but not for the reference ID; it is always set per request.
  • The verification type is derived, not requested. There is no verification_type field. The server works out Core, Basic or Full from what you ask for: any phone, address or license-photo option, or any field beyond name and address, makes it Full; name and address fields or the location check make it Basic; otherwise it is Core. The type determines which of your plan's verification allowances is used.
  • Encryption only applies when personal data is collected. If the request has no requested_fields, phone verification or cropped headshot from license, encryption_key_mode is ignored and treated as "none".
  • Quota is checked at creation. When you have no verifications of the needed type remaining, the request is rejected with a 402 and the code QUOTA_EXCEEDED.

Available Requested Fields

first_names
First name(s)
last_name
Last name
middle_names
Middle name(s)
dob
Date of birth (full)
dob_year
Birth year only
dob_year_month
Birth year & month
dob_month_day
Birth month & day
sex
Sex
street_address
Street address
city
City
state
State
postal_code
Postal code
license_number
License number
license_issue_date
License issue date
license_expiration_date
License expiration date
i
Only request the fields you actually need. The end user sees a list of every field you've requested on the consent screen, and requesting less builds more trust.

Example — Minimal Request

curl -X POST https://verify-api.ad-hoc.app/verification \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{}'

Example — Using a Template

curl -X POST https://verify-api.ad-hoc.app/verification \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "template_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" }'

Example — Full Custom Request

curl -X POST https://verify-api.ad-hoc.app/verification \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "custom_message": "Please verify your identity to complete your application.", "webhook_url": "https://api.yourcompany.com/webhooks/verify", "webhook_headers": { "X-API-Key": "your-secret-key" }, "requested_fields": ["first_names", "last_name", "dob"], "phone_verification": true, "address_verification": true, "location_consistency": true, "external_id": "PO12345", "notification_emails": "[email protected]", "expires_in_days": 7 }'
Response 201
{ "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "url": "https://adhocverify.com/verify/f47ac10b-58cc-4372-a567-0e02b2c3d479", "created_at": "2026-03-26T12:00:00.000Z" }

Send the url to the person being verified (via email, SMS, or embedded in your app). They open it on their mobile device to complete the flow — no app install required.

Rate limit: 100 requests per hour.

Get Verification

GET /verification?id={verification_id}

Retrieve the status and results of a verification. For completed verifications, the response contains the same data that was (or would be) delivered via webhook.

Request
curl "https://verify-api.ad-hoc.app/verification?id=f47ac10b-58cc-4372-a567-0e02b2c3d479" \ -H "Authorization: Bearer <token>"

The response shape depends on the verification status:

Pending or Started

{ "id": "f47ac10b-...", "status": "pending", "created_at": "2026-03-26T12:00:00.000Z" }

When status is "started", a started_at timestamp is also included.

Completed

{ "id": "f47ac10b-...", "status": "completed", "created_at": "2026-03-26T12:00:00.000Z", "completed_at": "2026-03-26T12:05:30.000Z", "result": { "request_id": "f47ac10b-...", "external_id": "PO12345", "verification": { "license_appears_real": true, "license_expired": false, "webcam_appears_live": true, "webcam_face_matches_license_photo": true, "face_match_score": "definite_match", "over_21": true, "over_18": true, "ip_found": true, "is_proxy": false }, "license_data": { "first_names": "JOHN", "last_name": "DOE", "dob": "1990-01-15" }, "address_verification": { "address_valid": true, "full_name_match": true, "last_name_match": true }, "location_consistency": { "status": "consistent", "anonymized": false, "ip_verdict": "probably", "ip_score": 78, "comparison_level": "city", "distance_km": 12, "distance_mi": 7.5, "expected_range_km": 40, "reasons": [], "ip_changed_before_submit": false } }, "webhook_status": "delivered", "webhook_attempts": 1 }

result is the same payload your webhook receives. The address_verification and location_consistency objects appear only when those checks were requested and able to run — see Indicators. If the personal data was purged, a pii_purged_at timestamp is also included.

Expired or Canceled

{ "id": "f47ac10b-...", "status": "expired", "created_at": "2026-03-26T12:00:00.000Z" }

A "canceled" request has the same shape. Canceled and expired requests carry no result data.

Manual Review

Same shape as completed, but status is "manual_review". This means the face match was inconclusive and the end user submitted their images for human review.

Rate limit: 300 requests per hour.

Verification Statuses

pending
Created, waiting for end user to open the link
started
End user has opened the link and begun the flow
completed
Verification flow finished successfully
manual_review
Face match failed; end user requested manual review
expired
Link expired before completion
canceled
Canceled from the dashboard before completion; the link no longer works and the verification slot it held is released

Indicators

Indicators check details from the license against outside data sources and add extra confidence in who you're dealing with. They appear in result (and in the webhook payload) alongside the standard checks. For how to interpret them, and why a negative result is not proof of fraud, see Data Fields & Indicators.

!
Indicators are omitted, not set to false, when they could not run. For example, the address and location checks need the barcode on the back of the license, and an outside data source may be temporarily unavailable. Always test for the presence of the object before reading it.

address_verification

Present when address_verification was requested. Every Full request can include it.

address_valid
true if the license address is a real, deliverable postal address
full_name_match
true if the first and last name on the license are on record at that address
last_name_match
true if the last name matched (often a household member)

location_consistency

Present when location_consistency was requested. Available on Basic and Full requests. Informational only — it never changes the overall result.

status
"consistent", "inconsistent", or "inconclusive". Inconclusive is common and not a negative signal on its own.
anonymized
true for a VPN, proxy, Tor or hosting-provider connection; null when that data was unavailable
ip_verdict / ip_score
How useful the connection's location was for the comparison: "yes", "probably", "maybe" or "no", with a 0–100 score
comparison_level
The precision the comparison was made at: "city", "subdivision", "country" or "none"
distance_km / distance_mi
Approximate distance to the license address; null unless a distance could be measured
expected_range_km
The distance considered normal for this connection; null when not applicable
reasons
Array of machine-readable codes explaining the status, such as vpn, tor, hosting_provider, imprecise_ip_location, ip_not_located, address_not_geocoded or outside_expected_range
ip_changed_before_submit
true if the connection changed between the barcode step and submission

Proxy detection (in verification)

Included in every verification's verification object, for all types.

ip_found
true if the connection's IP address was found in the intelligence data
is_proxy
true if the connection came through a known proxy or anonymizing service
proxy_type / proxy_description
Only present when is_proxy is true

Face Match Scores

The face_match_score field in the verification result indicates how confidently the selfie face matched the license photo:

definite_match
Strong match on all signals
likely_match
Passes primary threshold
possible_match
Borderline — may warrant manual review
no_match
Faces do not appear to match

See Face Match Scoring for a detailed breakdown of how scores are computed.

Error Responses

All errors follow a consistent format:

{ "error": "Human-readable description", "code": "MACHINE_READABLE_CODE" }

Validation errors include a details array listing each issue:

{ "error": "Validation failed", "code": "VALIDATION_ERROR", "details": [ "webhook_url must be an HTTPS URL", "Invalid requested_fields: invalid_field" ] }

Error Codes

MISSING_AUTH
401 — No Authorization header provided
UNAUTHORIZED
401 — Token is invalid or expired
INVALID_CREDENTIALS
401 — Wrong email or password
VALIDATION_ERROR
400 — Request body failed validation
NOT_FOUND
404 — Verification or template not found
QUOTA_EXCEEDED
402 — No verifications of the needed type remain on your plan. The response includes the verification_type that ran out.
RATE_LIMITED
429 — Too many requests
INTERNAL_ERROR
500 — Unexpected server error

Encryption

When encryption_key_mode is set:

  • passphrase — PII is encrypted in the end user's browser with a one-off ECDH P-256 public key derived from a passphrase you chose in the dashboard (ECDH + HKDF-SHA256 + AES-256-GCM). The encrypted data is passed through along with an encryption_key object holding the non-secret Argon2id salt and parameters, so you can re-derive the private key from your passphrase. We cannot decrypt it.
  • custom — PII is encrypted with your RSA public key (RSA-OAEP + AES-256-GCM). The encrypted envelope is passed through for you to decrypt with your private key.
  • none — PII fields are returned as plaintext, filtered to only your requested_fields. Intended for testing with your own information.
  • shared (legacy) — verifications created before passphrase keys existed. Their data is viewable only on the dashboard and appears as a placeholder in API and webhook responses.

Rate Limits

POST /auth
5 requests per minute (per email)
POST /verification
100 requests per hour
GET /verification
300 requests per hour

Ready to integrate? Log in and grab your credentials.

Go to Dashboard →