For developers

API documentation

Everything you need to integrate document verification: REST + JSON, signed webhooks, free sandbox, OpenAPI and SDK.

Introduction

The Validento API verifies documents and income inside your product: send a person's documents (or a link so they upload them) and receive a score, a decision and the deviations found — by webhook or by polling.

Base URLhttps://api.validento.com
FormatJSON (UTF-8) · HTTPS · REST
Versionv1 · header Validento-Version: 2026-10-01
Environmentsvld_test_… sandbox (free, deterministic results) · vld_live_… production
DataProcessed in the EU. Documents are not stored after the analysis.

Quickstart

  1. Create a sandbox key

    Enterprise dashboard → Developers → API keys → “+ Sandbox key”. It is shown once: store it in your secrets manager.

  2. Check the key
    curl https://api.validento.com/v1/me \
      -H "Authorization: Bearer vld_test_YOUR_SANDBOX_KEY"
  3. Create a screening
    curl https://api.validento.com/v1/screenings \
      -H "Authorization: Bearer vld_test_YOUR_SANDBOX_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)" \
      -d '{
        "applicant": { "name": "Ana García", "email": "ana@example.com" },
        "purpose": "rental",
        "country": "ES",
        "reference": "case-1042",
        "consent_confirmed": true,
        "case_details": { "monthly_rent": 1200 },
        "documents": [
          { "type": "payslip", "url": "https://files.yourapp.com/signed/payslip-sept.pdf" },
          { "type": "bank_statement", "media_type": "application/pdf", "data": "JVBERi0xLjQK..." }
        ],
        "metadata": { "tenant_id": "t_981" }
      }'
  4. Receive the result

    Register a webhook endpoint (dashboard or API) and receive screening.completed. Or poll GET /v1/screenings/{id} until status is no longer processing (usually 10–60 s; sandbox: instant).

  5. Go live

    When your integration works in sandbox, your account manager enables live. Swap the key for a vld_live_… one; the code stays the same.

Try it

Paste a sandbox key and call the API directly. The key stays in this tab (sessionStorage) and only travels to api.validento.com.

Authentication & keys

Send the key on every request as Authorization: Bearer vld_…. Only a hash is stored: if you lose it, create another. Keys are server-side: never in a browser or mobile app.

Scopes

A key has full access (*) or is restricted: give each system only what it needs. write includes read. Without the scope: 403 missing_scope.

ScopeAllows
screenings:readRead screenings and reports.
screenings:writeCreate and delete screenings (+ read).
links:readRead verification links.
links:writeCreate and cancel links (+ read). Ideal for the backend of your frontend.
webhooks:read / webhooks:writeManage endpoints; resend events.
events:readRead events (30 days).
usage:readPeriod usage and invoices.
settings:read / settings:writeSettings: rules, branding, format, notifications.

Zero-downtime rotation

“Rotate” creates a new key and keeps the old one alive for the grace period you choose (0 h, 1 h, 24 h or 7 days). Deploy the new one, confirm in the request log that the old one is no longer used, and let it expire. Key exposed? Revoke it: it stops working within seconds.

IP allowlist

Optional, per account: exact IPs or IPv4 CIDR ranges (203.0.113.0/24). Other IPs get 403 ip_not_allowed. Ask your account manager.

Sandbox

vld_test_ keys analyse nothing and charge nothing, and return deterministic results so you can test every path in your code. Test and live objects are fully separate (webhooks and events too).

sandbox_outcomeor in reference / nameResult
approve (default)—score 86 · approve
reviewreviewscore 58 · review · 1 medium deviation
declinedeclinescore 28 · decline · 1 high deviation
failfailstatus failed · screening.failed
The decision follows your decision rules, in sandbox too: if you raise “approve from” to 90, the sandbox 86 becomes review.

Requests & responses

Bodies in JSON (Content-Type: application/json). Dates in ISO 8601 UTC; amounts in cents (integers). Every response carries:

Validento-Request-IdInclude it when you contact support.
Validento-VersionAPI version that answered.
RateLimit-Limit / -Remaining / -ResetYour per-minute quota.
Idempotent-Replayed: trueReplayed response of an Idempotency-Key.

Idempotency

Send Idempotency-Key: <uuid> on every POST. If the network fails and you retry with the same key within 24 h, you get the original response and no duplicate is created. The same key with another body returns 409 idempotency_conflict. The SDK does this for you.

Pagination

Lists return { object:"list", data:[…], has_more }, newest first. Parameters: limit (1–100), starting_after=<id> (next page), ending_before=<id> (previous), created[gte] and created[lte] (unix seconds or ISO 8601).

curl -G https://api.validento.com/v1/screenings \
  -H "Authorization: Bearer vld_test_YOUR_SANDBOX_KEY" \
  -d limit=100 -d status=completed -d "created[gte]=1759276800" \
  -d "metadata[tenant_id]=t_981"

Metadata

Store your own IDs in metadata (up to 20 keys of ≤ 40 characters, string values ≤ 500). It is returned as given, travels in webhooks and can be filtered with metadata[key]=value.

Versioning & changes

v1 does not break. We add fields, endpoints, events and enum values without notice: ignore what you don't know. An incompatible change gets a new dated version, with at least 12 months of transition and notice by email.

Errors

Errors use HTTP status codes and a stable body. Code against error.code (stable), not the message.

JSON
{
  "error": {
    "type": "invalid_request_error",
    "code": "consent_required",
    "message": "\"consent_confirmed\": true is required — confirm the applicant agreed to the check.",
    "param": "consent_confirmed",
    "doc_url": "https://validento.com/developers#error-consent_required",
    "request_id": "req_Fq9MsetWX27BncBC"
  }
}

Retry safely (same Idempotency-Key) on 429, 500 and network errors. Don't retry other 4xx without changing the request.

invalid_api_key 401 authentication_error

Key missing, mistyped, revoked or expired. → Check the Authorization: Bearer vld_… header.

missing_scope 403 permission_error

The key is restricted and lacks this scope. → Use another key, or create one with the scope named in the message.

ip_not_allowed 403 permission_error

The IP is not on your allowlist. → Add your server's egress IP in the dashboard.

live_not_enabled 403 permission_error

Live access is not enabled yet. → Keep using sandbox; your account manager enables live after the review.

account_suspended 403 permission_error

Account suspended. → Contact your account manager.

service_not_enabled 403 permission_error

The service is not in your agreement. → Ask your account manager.

insufficient_balance 402 billing_error

Prepaid balance too low. → Top up, or turn on auto top-up.

credit_limit_reached 402 billing_error

Credit limit reached. → Pay the open invoice or ask for a higher limit.

rate_limited 429 rate_limit_error

Too many requests. → Wait Retry-After seconds and retry.

invalid_json 400 invalid_request_error

The body is not valid JSON. → Send Content-Type: application/json and a JSON object.

invalid_parameter 400 invalid_request_error

A parameter is invalid (see param). → Fix the field named in error.param.

invalid_purpose 400 invalid_request_error

Unknown purpose. → Use rental, purchase, business_partner or civil_matter.

invalid_metadata 400 invalid_request_error

metadata breaks the limits. → Max 20 keys of ≤ 40 chars, string values ≤ 500.

applicant_required 400 invalid_request_error

applicant.name is missing.

documents_required 400 invalid_request_error

No documents. → Send 1 to 20 documents.

too_many_documents 400 invalid_request_error

More than 20 documents. → Split the case or combine pages into one PDF.

invalid_document 400 invalid_request_error

Document without url or data+media_type.

unsupported_media_type 415 invalid_request_error

Unsupported format. → PDF, JPEG, PNG or WebP.

document_too_large 413 invalid_request_error

A document exceeds 10 MB. → Compress the image or lower the resolution.

documents_too_large 413 invalid_request_error

Documents exceed 20 MB in total.

document_url_unreachable 422 invalid_request_error

We could not download a URL (15 s). → Use a signed URL valid ≥ 10 min, or send base64.

invalid_settings 400 invalid_request_error

Invalid settings (see message).

invalid_url 400 invalid_request_error

The URL must start with https://.

too_many_endpoints 409 invalid_request_error

At most 10 endpoints.

idempotency_conflict 409 idempotency_error

Idempotency-Key reused with another body. → Generate a new key per operation.

not_found 404 invalid_request_error

No such object (or it belongs to the other environment). → Test keys don't see live objects and vice versa.

method_not_allowed 405 invalid_request_error

Method not allowed on this path.

server_error 500 api_error

Our fault. → Safe to retry (with the same Idempotency-Key).

Limits

Requests per key120 / min live · 60 / min sandbox · raised on request
Documents per screening1–20 · ≤ 10 MB each · ≤ 20 MB in total
FormatsPDF, JPEG, PNG, WebP
Download of url15 s per document
Webhook endpoints10 per account · answer within 10 s
Events retrievable30 days
Screening retentionper your contract (default 365 days); deleted automatically afterwards

Over the limit: 429 rate_limited with Retry-After. For bulk loads, spread requests or ask for a higher limit.

Screenings

A screening = one person (or company) with 1 to 20 documents. It is asynchronous: created with status: "processing", ending in completed or failed. Only completed live screenings are billed.

Create a screening

POST/v1/screeningsscreenings:write
applicant.name stringrequired

Name of the person (or company).

applicant.email / phone / date_of_birth / id_number / company / address / country string

More data helps the identity cross-check. date_of_birth as YYYY-MM-DD, country ISO alpha-2.

documents[] arrayrequired

1–20 objects: { url } (https, we download it) or { data, media_type } (base64). Optional type as a hint: payslip, bank_statement, id, employment_contract, tax_return, rental_contract, purchase_agreement, registration_docs, annual_accounts…

consent_confirmed truerequired

You confirm the person agreed to the check.

purpose enum

rental · purchase · business_partner · civil_matter. Changes what is weighed. Default: your setting.

country string

Country of the documents (ISO alpha-2). Improves extraction.

case_details object

Facts the engine compares with the documents: rent, price, address, employer… Each difference becomes a deviation. Flat, ≤ 30 snake_case keys. E.g. { "monthly_rent": 1200, "employer": "Acme SL" }

reference string

Your own ID (≤ 120). Filterable.

metadata object

See metadata.

lang en | es

Language of the summary and deviations.

sandbox_outcome enum

Sandbox only: approve, review, decline, fail.

curl https://api.validento.com/v1/screenings \
  -H "Authorization: Bearer vld_test_YOUR_SANDBOX_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "applicant": { "name": "Ana García", "email": "ana@example.com" },
    "purpose": "rental",
    "country": "ES",
    "reference": "case-1042",
    "consent_confirmed": true,
    "case_details": { "monthly_rent": 1200 },
    "documents": [
      { "type": "payslip", "url": "https://files.yourapp.com/signed/payslip-sept.pdf" },
      { "type": "bank_statement", "media_type": "application/pdf", "data": "JVBERi0xLjQK..." }
    ],
    "metadata": { "tenant_id": "t_981" }
  }'

Response 202 with the object in processing. Always use Idempotency-Key.

The screening object

JSON
{
  "id": "6c1f6a2e-4b1d-4f7e-9c55-2a8f0d7b3e11",
  "object": "screening",
  "environment": "live",
  "status": "completed",
  "channel": "api",
  "reference": "case-1042",
  "purpose": "rental",
  "country": "ES",
  "applicant": { "name": "Ana García", "email": "ana@example.com" },
  "document_count": 3,
  "decision": "approve",
  "score": 82,
  "result": {
    "score": 82,
    "decision": "approve",
    "display_score": "82/100",
    "summary": "Documents are consistent; net income matches the bank statements.",
    "detected_document_type": "payslip",
    "detected_country": "ES",
    "documents_checked": 3,
    "deviations": [
      { "field": "employer_name", "severity": "low", "note": "Abbreviated on one payslip" }
    ],
    "fields": [ { "name": "net_monthly_income", "value": "3180.00" } ],
    "income": { "monthly_amount": 3180, "currency": "EUR" },
    "breakdown": { "document_integrity": 88, "financial_capacity": 79, "identity": 84 }
  },
  "case_details": { "monthly_rent": 1200 },
  "error": null,
  "metadata": { "tenant_id": "t_981" },
  "link_id": null,
  "created_at": "2026-10-04T09:12:03.120Z",
  "completed_at": "2026-10-04T09:12:41.877Z"
}
score 0–100

Always the same scale; your rules decide.

decision approve | review | decline

Per your decision rules (default ≥ 70 / ≥ 45).

result.deviations[] array

What doesn't add up: between documents, against case_details, or signs of tampering. severity low/medium/high.

result.fields[] array

Extracted data (employer, net pay, partial IBAN, dates…).

result.income object

Detected monthly income and currency.

result.breakdown object

Sub-scores 0–100: document integrity, financial capacity, identity.

result.display_score string

The score on your scale (82/100, 8.2/10 or B).

error string | null

When failed: engine_unavailable or another reason. Not charged; you can retry.

Which parts of result you receive is up to you in result_format.

List & filter

GET/v1/screeningsscreenings:read

Filters: reference, status, decision, purpose, channel (api/link/dashboard), link_id, metadata[key], created[gte|lte] + pagination.

Retrieve, report & erase

GET/v1/screenings/{id}screenings:read
GET/v1/screenings/{id}/report?lang=enscreenings:read

Printable HTML report in your branding. Turn it into a PDF with any browser or headless Chrome (Puppeteer: page.pdf()).

DELETE/v1/screenings/{id}screenings:write

Permanent erase (GDPR): removes the screening and the person's data on its link; billed usage is kept without personal data. Emits screening.deleted.

Webhooks

We send you a signed POST when something changes. Each environment has its own endpoints.

EventWhen
screening.createdScreening created (status processing).
screening.completedResult available: score, decision, result.
screening.failedCould not be completed (not charged).
screening.deletedErased (GDPR) via API or dashboard.
link.createdVerification link created.
link.completedThe person submitted documents; data.screening_id.
link.canceledLink canceled.
link.expiredThe link expired unused.
pingTest from the dashboard or the API.
HTTP
POST /your/webhook HTTP/1.1
Content-Type: application/json
Validento-Signature: t=1759569161,v1=5f2b0c8e…
Validento-Event: screening.completed
Validento-Event-Id: evt_8sK2pQ…
Validento-Delivery: 2b1c…
Validento-Version: 2026-10-01
{
  "id": "evt_8sK2pQ…",
  "object": "event",
  "type": "screening.completed",
  "environment": "live",
  "api_version": "2026-10-01",
  "created": 1759569161,
  "data": { "id": "6c1f6a2e-…", "object": "screening", "status": "completed", "decision": "approve", "score": 82, "…": "…" }
}

Verify the signature

v1 = hex HMAC-SHA256 of "{t}.{raw body}" with your whsec_… secret. Use the exact body received (don't re-serialise), compare in constant time and reject timestamps older than 5 minutes.

# Go
func verify(raw []byte, header, secret string) bool {
  var t, v1 string
  for _, p := range strings.Split(header, ",") {
    kv := strings.SplitN(p, "=", 2)
    if len(kv) == 2 && kv[0] == "t" { t = kv[1] }
    if len(kv) == 2 && kv[0] == "v1" { v1 = kv[1] }
  }
  mac := hmac.New(sha256.New, []byte(secret))
  mac.Write([]byte(t + "."))
  mac.Write(raw)
  ts, _ := strconv.ParseInt(t, 10, 64)
  return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(v1)) && math.Abs(float64(time.Now().Unix()-ts)) <= 300
}

Delivery & retries

  • Answer 2xx within 10 s; process afterwards (queue).
  • Otherwise: retries after 1, 5, 30, 120, 360, 720 and 1440 minutes (~1 day).
  • “At least once” and no guaranteed order: dedupe on event.id and, if state matters, fetch the object with GET.
  • Your endpoint was down? Catch up with GET /v1/events and resend with POST /v1/events/{id}/resend.
  • Every delivery and attempt shows in the dashboard (Developers → Webhooks), with a resend button.

Manage endpoints by API

GET/v1/webhook-endpointswebhooks:read
POST/v1/webhook-endpointswebhooks:write
PATCH/v1/webhook-endpoints/{id}webhooks:write
DELETE/v1/webhook-endpoints/{id}webhooks:write
POST/v1/webhook-endpoints/{id}/roll-secretwebhooks:write
POST/v1/webhook-endpoints/{id}/testwebhooks:write
curl https://api.validento.com/v1/webhook-endpoints \
  -H "Authorization: Bearer vld_test_YOUR_SANDBOX_KEY" -H "Content-Type: application/json" \
  -d '{ "url": "https://api.yourapp.com/webhooks/validento", "events": ["screening.completed", "screening.failed", "link.completed"] }'
# → { "id": "…", "secret": "whsec_…" }   (the secret is shown once)

Events

Every event is kept for 30 days, with or without endpoints. Use them to reconcile, recover from outages, or build without webhooks.

GET/v1/events?type=screening.completed,link.completedevents:read
GET/v1/events/{id}events:read
POST/v1/events/{id}/resendwebhooks:write

resend delivers again to every subscribed endpoint, or to one with { "endpoint_id": "…" } (even if not subscribed).

curl -G https://api.validento.com/v1/events -H "Authorization: Bearer vld_test_YOUR_SANDBOX_KEY" \
  -d type=screening.completed -d "created[gte]=1759569000" -d limit=100

Settings

Your rules and branding, by API or in the dashboard (Business → Settings). They apply to live and sandbox. Send only what changes; null resets a field.

GET/v1/settingssettings:read
PATCH/v1/settingssettings:write
decision.approve_min / review_min 1–100

Decision thresholds (default 70 / 45).

branding.display_name / logo_url / primary_color / support_email / privacy_url string

Your brand on the upload page, emails and reports.

branding.hide_powered_by boolean

Hides “powered by Validento”.

link_defaults.* object

purpose, country, expiry_days, documents_requested, redirect_url, email_applicant, language.

result_format.summary / deviations / fields / income / breakdown / applicant boolean

Which parts you receive by API and webhooks.

result_format.score_scale 100 | 10 | grade

Format of display_score.

notifications.recipients / on_review / on_decline / on_failed / on_completed

Emails to your team per live screening.

curl -X PATCH https://api.validento.com/v1/settings \
  -H "Authorization: Bearer vld_test_YOUR_SANDBOX_KEY" -H "Content-Type: application/json" \
  -d '{ "decision": { "approve_min": 75 }, "result_format": { "fields": false, "score_scale": "10" }, "branding": { "primary_color": "#6D28D9" } }'

Usage & invoices

GET/v1/usageusage:read

Current period (20th to 19th), screenings, graduated amount, minimum, balance (prepaid) or credit limit (invoice).

GET/v1/invoicesusage:read

Monthly invoices and statements with a link to the Stripe invoice and its PDF.

SDK JavaScript

One file, no dependencies, for Node 18+, Deno, Bun and edge runtimes. Retries with backoff (honours Retry-After), automatic Idempotency-Key, pagination with for await, typed errors and webhook verification. Types in validento.d.ts.

⬇ validento.mjs ⬇ validento.d.ts

TypeScript
import Validento, { ValidentoError } from './validento.mjs';   // Deno: 'https://validento.com/sdk/validento.mjs'
const vd = new Validento(process.env.VALIDENTO_KEY!, { maxRetries: 3, timeoutMs: 60_000 });
try {
  const s = await vd.screenings.create({
    applicant: { name: 'Ana García' }, consent_confirmed: true, purpose: 'rental',
    documents: [{ url: signedUrl, type: 'payslip' }],
    case_details: { monthly_rent: 1200 }, metadata: { tenant_id: 't_981' },
  });
  const done = await vd.screenings.waitFor(s.id);    // polling helper; prefer webhooks in production
  console.log(done.decision, done.result?.deviations);
} catch (err) {
  if (err instanceof ValidentoError) console.error(err.status, err.code, err.param, err.requestId);
  else throw err;
}
console.log(vd.lastResponse?.rateLimit);

Another language? Generate a client from the OpenAPI 3.1 spec (openapi-generator, oapi-codegen, NSwag…) or import it into Postman / Insomnia.

Shell
# Postman: Import → Link → https://api.validento.com/v1/openapi.json
npx @openapitools/openapi-generator-cli generate -i https://api.validento.com/v1/openapi.json -g python -o ./validento-python
npx @openapitools/openapi-generator-cli generate -i https://api.validento.com/v1/openapi.json -g go -o ./validento-go

Security & data

  • TLS 1.2+ everywhere. Keys stored as SHA-256 hashes; webhook secrets are never shown again.
  • Processing and storage in the EU. Documents are analysed and not stored; we keep the result.
  • Retention per contract (default 365 days), automatic deletion afterwards; immediate erase with DELETE /v1/screenings/{id}.
  • You are the controller; Validento is the processor (DPA available). consent_confirmed records the basis of your check.
  • Per-key scopes, IP allowlist, rotation with grace, a log of every request (dashboard → Request log).
  • The result supports a decision; it is not legal advice nor a final automated decision: keep a human review for “review” and “decline”.

Changelog

2026-10-01
  • Scoped keys, Events API (30 days, resend), webhook endpoints by API, cancel and list links.
  • Up to 20 documents (20 MB), case_details, settings and result format, branded report, GDPR erase.
  • RateLimit-*, Validento-Version, Idempotent-Replayed headers; errors with param and doc_url; OpenAPI 3.1; JavaScript SDK.

Support

Write to info@validento.com with the Validento-Request-Id and the approximate time. Enterprise clients: your account manager too. We answer in European business hours, usually the same day.

No account yet? See Enterprise or contact sales.