ThisVerify API
A simple REST API: send a file, get a full JSON report.
Overview
The ThisVerify API takes a document (PDF or image) and returns a full forgery report: a 0–100 risk score, a verdict (low risk / review / high risk), findings with exact locations on the document, arithmetic checks and extracted data. It is a JSON REST API and works asynchronously: create a scan, then receive the result by webhook or by polling.
- 1. SubmitPOST /scans with the file. Immediate 202 with a scan id.
- 2. AnalysisEngines run in parallel — usually 20–60 seconds.
- 3. Resultscan.completed webhook, or GET /scans/{id}.
Authentication
Every request carries an API key in the Authorization header. Keys belong to the organization (not to one user), are created under "API & integrations" in the dashboard, are shown once, and are stored only as a hash. Keep one key per system and revoke each separately.
API keys and webhooks are available to organizations on the Business or Enterprise plan. Docs, examples and the spec are open to everyone, so you can plan the integration now. A key from an organization without a suitable plan gets 403 NO_API_ACCESS.
Authorization: Bearer tv_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxScopes
Each key has scopes. Give every system only what it needs.
| Field | Type | Description |
|---|---|---|
| scans:write | scope | Create scans |
| scans:read | scope | Read scans and reports |
| scans:delete | scope | Delete scans |
| usage:read | scope | Read usage and quota |
Quick start
Your first check in two calls:
curl -X POST https://www.thisverify.com/api/v1/scans \
-H "Authorization: Bearer $THISVERIFY_KEY" \
-H "Idempotency-Key: loan-88231-doc-1" \
-F "file=@transfer.pdf" \
-F 'context={"document_type":"bank_transfer","expected_amount":"12500","expected_name":"ישראל ישראלי"}' \
-F "client_reference=loan-88231"
# → 202 {"id":"8f1c…","object":"scan","status":"queued",…}
curl https://www.thisverify.com/api/v1/scans/8f1c… -H "Authorization: Bearer $THISVERIFY_KEY"Create a scan
Three ways to send the file: multipart (recommended), JSON with base64, or JSON with a public https URL we download from. Returns 202 with the scan in status queued. Requires scans:write. Each scan uses one unit of the organization’s monthly allowance; if analysis fails on our side the unit is refunded.
| Field | Type | Description |
|---|---|---|
| file | binary | The file (multipart). PDF, JPEG, PNG, WEBP or HEIC. |
| file.content_base64 | string | JSON alternative: file content as base64 (data URLs accepted), with file.name. |
| file_url | string | JSON alternative: a public https URL. Must return the file within 15 s, without redirects. Internal addresses are blocked. |
| context.document_type | enum | Document type hint: bank_transfer, payment_app, bank_statement, account_confirmation, balance_confirmation, check, deposit_receipt, card_statement, payslip, invoice, tax_form, id_document, utility_bill, loan_document, medical, contract, other |
| context.expected_amount | string | The amount you expect. A mismatch becomes a finding. |
| context.expected_name | string | Expected payee / account holder / employee name. |
| context.expected_date | YYYY-MM-DD | Expected date. |
| context.expected_reference | string | Expected reference / account number. |
| context.notes | string | Free-text context for the analyst (up to 1,000 chars). |
| client_reference | string | Your own id (application/case number). Returned everywhere and filterable. |
| metadata | object | Up to 20 key-value pairs (values up to 500 chars). In multipart, send as a JSON string. |
| lang | "he" | "en" | Report language. Default: he. |
POST /api/v1/scans
Content-Type: application/json
Idempotency-Key: 4c1f…
{
"file": { "name": "payslip-06.pdf", "content_base64": "JVBERi0xLjcK…" },
"context": { "document_type": "payslip", "expected_name": "Dana Levi" },
"client_reference": "app-55102",
"metadata": { "branch": "tel-aviv", "officer": "u-1182" },
"lang": "en"
}Retrieve a scan
Returns the scan. When status is completed it includes the full report. Add ?include=summary for the summary only (no report). Requires scans:read. While processing, stage shows the current step.
{
"id": "8f1c2a4e-…",
"object": "scan",
"status": "completed",
"verdict": "high_risk",
"risk_score": 91,
"document_type": "bank_transfer",
"issuer": "בנק הפועלים",
"channel": "app_screenshot",
"client_reference": "loan-88231",
"report": {
"summary": "…",
"recommended_actions": ["…"],
"findings": [
{
"id": "ai-1",
"category": "visual",
"severity": "high",
"confidence": 0.93,
"title": "Amount digits re-rendered",
"detail": "The digits of the amount use a different font weight and anti-aliasing…",
"box": { "page": 1, "box_2d": [412, 520, 446, 700] }
}
],
"checks": [ { "id": "arith", "label": "…", "status": "fail", "detail": "…" } ],
"extracted": { "amounts": ["12,500.00"], "dates": ["2026-10-05"], "iban": "IL62…" },
"forensic_analysis": { "pixel_variance": { "has_variance": true, "score": 0.88 } },
"engine": { "version": "…", "duration_ms": 31244, "layers": ["pdf", "metadata", "ela", "content", "ai"] }
}
}box_2d is [ymin, xmin, ymax, xmax] on a 0–1000 scale relative to the page, so you can draw it at any resolution.
List scans
Newest first, cursor-paginated. Requires scans:read.
| Field | Type | Description |
|---|---|---|
| limit | 1–100 | Page size. Default 25. |
| cursor | string | next_cursor from the previous page. |
| status | enum | queued · processing · completed · failed |
| verdict | enum | low_risk · review · high_risk |
| client_reference | string | Filter by your id. |
| created_after / created_before | ISO 8601 | Date range. |
{
"object": "list",
"data": [ { "id": "…", "object": "scan", "status": "completed", "verdict": "review", … } ],
"has_more": true,
"next_cursor": "eyJ0IjoiMjAyNi0xMC0w…"
}Delete a scan
Permanently deletes the scan, its report and the original file (including any training copies). Requires scans:delete. Use it for privacy / GDPR erasure requests.
Usage
The organization’s usage in the current period. Requires usage:read.
{
"object": "usage",
"plan": { "id": "business", "name": "Business" },
"period": { "start": "2026-10-01T00:00:00.000Z", "end": "2026-11-01T00:00:00.000Z" },
"used": 1840,
"limit": 5000,
"remaining": 3160,
"total_scans": 22114
}To check a key: GET /api/v1 returns the key’s organization and scopes.
Webhooks
Instead of polling, register an https endpoint under "API & integrations" and we POST when a scan finishes. Each endpoint has its own signing secret (whsec_…).
| Field | Type | Description |
|---|---|---|
| scan.completed | event | The scan finished. Includes verdict, risk_score, client_reference and metadata. Fetch GET /scans/{id} for the full report. |
| scan.failed | event | The scan failed (e.g. corrupt file). The unit was refunded. |
| ping | event | Test event sent from the "Send test" button. |
ThisVerify-Event: scan.completed
ThisVerify-Delivery: 5b0e… ← unique event id (dedupe on it)
ThisVerify-Signature: t=1791364364,v1=3f9a…
{
"id": "5b0e…",
"type": "scan.completed",
"created": "2026-10-07T09:13:21.004Z",
"data": {
"scan": {
"id": "8f1c…", "object": "scan", "status": "completed",
"verdict": "high_risk", "risk_score": 91, "document_type": "bank_transfer",
"client_reference": "loan-88231", "metadata": { "branch": "tel-aviv" },
"file": { "name": "transfer.pdf", "sha256": "…" }
}
}
}- • Return 2xx within 10 seconds. Do heavy work asynchronously after responding.
- • Retries: immediately, after 3 s and 10 s, then after 1 min, 5 min, 30 min, 2 h and 12 h.
- • Duplicates are possible — dedupe on ThisVerify-Delivery.
- • After 50 consecutive failures the endpoint is disabled; re-enable it from the dashboard.
Verifying signatures
Make sure every request came from us: compute HMAC-SHA256 over "{t}.{raw body}" with the webhook secret, compare to v1 in constant time, and reject requests whose t is older than 5 minutes.
import crypto from 'node:crypto';
export function verifyThisVerify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected), b = Buffer.from(parts.v1 || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: app.post('/hooks/thisverify', express.raw({ type: 'application/json' }), (req, res) => {
// if (!verifyThisVerify(req.body.toString(), req.get('ThisVerify-Signature'), process.env.TV_WHSEC)) return res.sendStatus(400);
// res.sendStatus(200); queue.push(JSON.parse(req.body));
// });Idempotency
Send an Idempotency-Key header (up to 200 chars, e.g. your document id) on every POST /scans. Retrying with the same key and file returns the same scan (200 with Idempotent-Replayed: true) without another charge. The same key with a different file returns 409. Keys are kept for 7 days.
Rate limits
Limits are per organization per minute (default 120; enterprise plans by agreement). Every response includes RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset. When exceeded you get 429 with Retry-After in seconds. Maximum file size: 10 MB.
Errors
Every error has the same shape with a stable code and a request_id (send it to support).
HTTP/1.1 402 Payment Required
X-Request-Id: req_0c51d0a3e8b14f2a9d11
{ "error": { "code": "QUOTA_EXCEEDED", "message": "The organization has used its monthly allowance.", "request_id": "req_0c51d0a3e8b14f2a9d11" } }| HTTP | code | Meaning |
|---|---|---|
| 401 | NO_KEY | Missing API key. Send "Authorization: Bearer tv_live_…". |
| 401 | INVALID | The API key is invalid or revoked. |
| 403 | SUSPENDED | The organization or key owner is suspended. |
| 403 | NO_API_ACCESS | API access requires a paid plan that includes the API (Business or Enterprise). Upgrade from the dashboard or contact sales. |
| 403 | INSUFFICIENT_SCOPE | The API key does not have the required scope. |
| 429 | RATE_LIMITED | Too many requests. Retry after the time in the Retry-After header. |
| 400 | INVALID_REQUEST | The request is malformed. |
| 400 | FILE_REQUIRED | Provide a file as multipart "file", JSON "file.content_base64", or JSON "file_url". |
| 400 | EMPTY_FILE | The file is empty. |
| 413 | FILE_TOO_LARGE | The file exceeds the size limit. |
| 415 | UNSUPPORTED_FILE | Unsupported file type. Use PDF, JPEG, PNG, WEBP or HEIC. |
| 400 | FILE_URL_REJECTED | file_url must be a public https URL that returns the file within 15 seconds. |
| 402 | QUOTA_EXCEEDED | The organization has used its monthly allowance. |
| 503 | AI_NOT_CONFIGURED | The analysis service is temporarily unavailable. |
| 404 | NOT_FOUND | Resource not found. |
| 409 | CONFLICT | An Idempotency-Key was reused with a different request. |
| 500 | SERVICE_ERROR | Unexpected error. Retry with the same Idempotency-Key. |
The scan object
| Field | Type | Description |
|---|---|---|
| status | enum | queued → processing → completed / failed |
| verdict | enum | low_risk — no significant signs · review — needs a human look · high_risk — clear signs of forgery |
| risk_score | 0–100 | Weighted score across all analysis layers. |
| document_type / issuer / channel | string | Document type, issuer (bank/employer/HMO) and capture channel (native PDF, screenshot, photo…). |
| report.findings[] | array | Findings: category, severity (info/low/medium/high/critical), confidence (0–1), title, detail and box. |
| report.checks[] | array | Deterministic checks: arithmetic, balances, VAT, IBAN, ID check digit, bank code, weekday and more — pass/warn/fail. |
| report.extracted | object | Extracted data: amounts, dates, names, accounts, transactions. |
| label | enum | null | Your human label (genuine/fraud), if set in the dashboard. |
OpenAPI
The full OpenAPI 3.1 specification — import it into Postman or Insomnia, or generate an SDK in any language.