Everything you need to integrate document verification: REST + JSON, signed webhooks, free sandbox, OpenAPI and SDK.
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 URL | https://api.validento.com |
|---|---|
| Format | JSON (UTF-8) · HTTPS · REST |
| Version | v1 · header Validento-Version: 2026-10-01 |
| Environments | vld_test_… sandbox (free, deterministic results) · vld_live_… production |
| Data | Processed in the EU. Documents are not stored after the analysis. |
Enterprise dashboard → Developers → API keys → “+ Sandbox key”. It is shown once: store it in your secrets manager.
curl https://api.validento.com/v1/me \
-H "Authorization: Bearer vld_test_YOUR_SANDBOX_KEY"import Validento from 'https://validento.com/sdk/validento.mjs';
const vd = new Validento(process.env.VALIDENTO_KEY);
console.log(await vd.me());import os, requests
r = requests.get("https://api.validento.com/v1/me", headers={"Authorization": f"Bearer {os.environ['VALIDENTO_KEY']}"})
print(r.json())$ch = curl_init("https://api.validento.com/v1/me");
curl_setopt_array($ch, [CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("VALIDENTO_KEY")], CURLOPT_RETURNTRANSFER => true]);
echo curl_exec($ch);
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" }
}'import Validento from './validento.mjs';
const vd = new Validento(process.env.VALIDENTO_KEY);
const screening = await vd.screenings.create({
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: fs.readFileSync('statement.pdf').toString('base64') },
],
metadata: { tenant_id: 't_981' },
});
console.log(screening.id, screening.status); // "processing"import os, uuid, base64, requests
H = {"Authorization": f"Bearer {os.environ['VALIDENTO_KEY']}"}
r = requests.post("https://api.validento.com/v1/screenings",
headers={**H, "Idempotency-Key": str(uuid.uuid4())},
json={
"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": base64.b64encode(open("statement.pdf", "rb").read()).decode()},
],
"metadata": {"tenant_id": "t_981"},
}, timeout=60)
r.raise_for_status()
print(r.json()["id"])<?php
function validento(string $method, string $path, ?array $body = null): array {
$ch = curl_init("https://api.validento.com" . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => array_filter([
"Authorization: Bearer " . getenv("VALIDENTO_KEY"), "Content-Type: application/json",
$method === "POST" ? "Idempotency-Key: " . bin2hex(random_bytes(16)) : null,
]),
CURLOPT_POSTFIELDS => $body ? json_encode($body) : null,
]);
$res = json_decode(curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) >= 400) throw new RuntimeException($res["error"]["code"] ?? "error");
return $res;
}
$s = validento("POST", "/v1/screenings", [
"applicant" => ["name" => "Ana García"], "purpose" => "rental", "consent_confirmed" => true,
"case_details" => ["monthly_rent" => 1200],
"documents" => [["type" => "payslip", "url" => "https://files.yourapp.com/signed/payslip-sept.pdf"]],
]);
echo $s["id"];
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).
When your integration works in sandbox, your account manager enables live. Swap the key for a vld_live_… one; the code stays the same.
Paste a sandbox key and call the API directly. The key stays in this tab (sessionStorage) and only travels to api.validento.com.
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.
A key has full access (*) or is restricted: give each system only what it needs. write includes read. Without the scope: 403 missing_scope.
| Scope | Allows |
|---|---|
screenings:read | Read screenings and reports. |
screenings:write | Create and delete screenings (+ read). |
links:read | Read verification links. |
links:write | Create and cancel links (+ read). Ideal for the backend of your frontend. |
webhooks:read / webhooks:write | Manage endpoints; resend events. |
events:read | Read events (30 days). |
usage:read | Period usage and invoices. |
settings:read / settings:write | Settings: rules, branding, format, notifications. |
“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.
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.
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_outcome | or in reference / name | Result |
|---|---|---|
approve (default) | — | score 86 · approve |
review | review | score 58 · review · 1 medium deviation |
decline | decline | score 28 · decline · 1 high deviation |
fail | fail | status failed · screening.failed |
Bodies in JSON (Content-Type: application/json). Dates in ISO 8601 UTC; amounts in cents (integers). Every response carries:
Validento-Request-Id | Include it when you contact support. |
Validento-Version | API version that answered. |
RateLimit-Limit / -Remaining / -Reset | Your per-minute quota. |
Idempotent-Replayed: true | Replayed response of an Idempotency-Key. |
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.
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"for await (const s of vd.screenings.listAll({ status: 'completed', created: { gte: new Date('2026-10-01') }, metadata: { tenant_id: 't_981' } })) {
console.log(s.id, s.decision);
}params = {"limit": 100, "status": "completed"}
while True:
page = requests.get("https://api.validento.com/v1/screenings", headers=H, params=params).json()
for s in page["data"]:
print(s["id"], s["decision"])
if not page["has_more"]:
break
params["starting_after"] = page["data"][-1]["id"]$params = ["limit" => 100, "status" => "completed"];
do {
$page = json_decode(file_get_contents("https://api.validento.com/v1/screenings?" . http_build_query($params), false, $ctx), true);
foreach ($page["data"] as $s) echo $s["id"], " ", $s["decision"], "\n";
$params["starting_after"] = end($page["data"])["id"] ?? null;
} while ($page["has_more"]);
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.
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 use HTTP status codes and a stable body. Code against error.code (stable), not the message.
{
"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_errorKey missing, mistyped, revoked or expired. → Check the Authorization: Bearer vld_… header.
missing_scope 403 permission_errorThe 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_errorThe IP is not on your allowlist. → Add your server's egress IP in the dashboard.
live_not_enabled 403 permission_errorLive access is not enabled yet. → Keep using sandbox; your account manager enables live after the review.
account_suspended 403 permission_errorAccount suspended. → Contact your account manager.
service_not_enabled 403 permission_errorThe service is not in your agreement. → Ask your account manager.
insufficient_balance 402 billing_errorPrepaid balance too low. → Top up, or turn on auto top-up.
credit_limit_reached 402 billing_errorCredit limit reached. → Pay the open invoice or ask for a higher limit.
rate_limited 429 rate_limit_errorToo many requests. → Wait Retry-After seconds and retry.
invalid_json 400 invalid_request_errorThe body is not valid JSON. → Send Content-Type: application/json and a JSON object.
invalid_parameter 400 invalid_request_errorA parameter is invalid (see param). → Fix the field named in error.param.
invalid_purpose 400 invalid_request_errorUnknown purpose. → Use rental, purchase, business_partner or civil_matter.
invalid_metadata 400 invalid_request_errormetadata breaks the limits. → Max 20 keys of ≤ 40 chars, string values ≤ 500.
applicant_required 400 invalid_request_errorapplicant.name is missing.
consent_required 422 invalid_request_errorconsent_confirmed: true is missing. → Confirm the person agreed to the check.
documents_required 400 invalid_request_errorNo documents. → Send 1 to 20 documents.
too_many_documents 400 invalid_request_errorMore than 20 documents. → Split the case or combine pages into one PDF.
invalid_document 400 invalid_request_errorDocument without url or data+media_type.
unsupported_media_type 415 invalid_request_errorUnsupported format. → PDF, JPEG, PNG or WebP.
document_too_large 413 invalid_request_errorA document exceeds 10 MB. → Compress the image or lower the resolution.
documents_too_large 413 invalid_request_errorDocuments exceed 20 MB in total.
document_url_unreachable 422 invalid_request_errorWe could not download a URL (15 s). → Use a signed URL valid ≥ 10 min, or send base64.
invalid_settings 400 invalid_request_errorInvalid settings (see message).
invalid_url 400 invalid_request_errorThe URL must start with https://.
too_many_endpoints 409 invalid_request_errorAt most 10 endpoints.
link_not_pending 409 invalid_request_errorOnly a pending link can be canceled.
idempotency_conflict 409 idempotency_errorIdempotency-Key reused with another body. → Generate a new key per operation.
link_expired 410 invalid_request_errorLink expired or already used. → Create a new link.
not_found 404 invalid_request_errorNo 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_errorMethod not allowed on this path.
server_error 500 api_errorOur fault. → Safe to retry (with the same Idempotency-Key).
| Requests per key | 120 / min live · 60 / min sandbox · raised on request |
| Documents per screening | 1–20 · ≤ 10 MB each · ≤ 20 MB in total |
| Formats | PDF, JPEG, PNG, WebP |
| Download of url | 15 s per document |
| Webhook endpoints | 10 per account · answer within 10 s |
| Events retrievable | 30 days |
| Screening retention | per 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.
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.
/v1/screeningsscreenings:writeapplicant.name stringrequiredName of the person (or company).
applicant.email / phone / date_of_birth / id_number / company / address / country stringMore data helps the identity cross-check. date_of_birth as YYYY-MM-DD, country ISO alpha-2.
documents[] arrayrequired1–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 truerequiredYou confirm the person agreed to the check.
purpose enumrental · purchase · business_partner · civil_matter. Changes what is weighed. Default: your setting.
country stringCountry of the documents (ISO alpha-2). Improves extraction.
case_details objectFacts 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 stringYour own ID (≤ 120). Filterable.
metadata objectSee metadata.
lang en | esLanguage of the summary and deviations.
sandbox_outcome enumSandbox 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" }
}'import Validento from './validento.mjs';
const vd = new Validento(process.env.VALIDENTO_KEY);
const screening = await vd.screenings.create({
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: fs.readFileSync('statement.pdf').toString('base64') },
],
metadata: { tenant_id: 't_981' },
});
console.log(screening.id, screening.status); // "processing"import os, uuid, base64, requests
H = {"Authorization": f"Bearer {os.environ['VALIDENTO_KEY']}"}
r = requests.post("https://api.validento.com/v1/screenings",
headers={**H, "Idempotency-Key": str(uuid.uuid4())},
json={
"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": base64.b64encode(open("statement.pdf", "rb").read()).decode()},
],
"metadata": {"tenant_id": "t_981"},
}, timeout=60)
r.raise_for_status()
print(r.json()["id"])<?php
function validento(string $method, string $path, ?array $body = null): array {
$ch = curl_init("https://api.validento.com" . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => array_filter([
"Authorization: Bearer " . getenv("VALIDENTO_KEY"), "Content-Type: application/json",
$method === "POST" ? "Idempotency-Key: " . bin2hex(random_bytes(16)) : null,
]),
CURLOPT_POSTFIELDS => $body ? json_encode($body) : null,
]);
$res = json_decode(curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) >= 400) throw new RuntimeException($res["error"]["code"] ?? "error");
return $res;
}
$s = validento("POST", "/v1/screenings", [
"applicant" => ["name" => "Ana García"], "purpose" => "rental", "consent_confirmed" => true,
"case_details" => ["monthly_rent" => 1200],
"documents" => [["type" => "payslip", "url" => "https://files.yourapp.com/signed/payslip-sept.pdf"]],
]);
echo $s["id"];
Response 202 with the object in processing. Always use Idempotency-Key.
{
"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–100Always the same scale; your rules decide.
decision approve | review | declinePer your decision rules (default ≥ 70 / ≥ 45).
result.deviations[] arrayWhat doesn't add up: between documents, against case_details, or signs of tampering. severity low/medium/high.
result.fields[] arrayExtracted data (employer, net pay, partial IBAN, dates…).
result.income objectDetected monthly income and currency.
result.breakdown objectSub-scores 0–100: document integrity, financial capacity, identity.
result.display_score stringThe score on your scale (82/100, 8.2/10 or B).
error string | nullWhen failed: engine_unavailable or another reason. Not charged; you can retry.
result you receive is up to you in result_format./v1/screeningsscreenings:readFilters: reference, status, decision, purpose, channel (api/link/dashboard), link_id, metadata[key], created[gte|lte] + pagination.
/v1/screenings/{id}screenings:read/v1/screenings/{id}/report?lang=enscreenings:readPrintable HTML report in your branding. Turn it into a PDF with any browser or headless Chrome (Puppeteer: page.pdf()).
/v1/screenings/{id}screenings:writePermanent erase (GDPR): removes the screening and the person's data on its link; billed usage is kept without personal data. Emits screening.deleted.
Don't have the documents? Create a link: the person uploads them on a page with your branding (or inside your app) and you receive link.completed + screening.completed.
/v1/verification-linkslinks:writeapplicant.name stringrequiredName the person sees.
applicant.email stringRequired with send_email.
send_email booleanWe email the link in your branding (replies go to your support email).
locale es | enLanguage of the page and email.
documents_requested string[]List the person sees (≤ 20).
expires_in_days 1–30Default: your setting (7).
redirect_url https URLWhere to go when done.
purpose / country / reference / case_details / metadata / brand_name As for a screening; carried over to the resulting screening.
curl https://api.validento.com/v1/verification-links \
-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",
"reference": "case-1042",
"documents_requested": ["ID", "Last 3 payslips"],
"send_email": true,
"locale": "es"
}'const link = await vd.verificationLinks.create({
applicant: { name: 'Ana García', email: 'ana@example.com' },
purpose: 'rental', reference: 'case-1042',
documents_requested: ['ID', 'Last 3 payslips'],
send_email: true, locale: 'es',
});
console.log(link.url, link.embed_url);link = requests.post("https://api.validento.com/v1/verification-links", headers={**H, "Idempotency-Key": str(uuid.uuid4())}, json={
"applicant": {"name": "Ana García", "email": "ana@example.com"},
"purpose": "rental", "reference": "case-1042",
"documents_requested": ["ID", "Last 3 payslips"],
"send_email": True, "locale": "es",
}).json()
print(link["url"])$link = validento("POST", "/v1/verification-links", [
"applicant" => ["name" => "Ana García", "email" => "ana@example.com"],
"purpose" => "rental", "reference" => "case-1042",
"send_email" => true, "locale" => "es",
]);
echo $link["url"];
url and embed_url carry a secret token and are only returned on create. Store them if you need them later.Create the link on your server and pass embed_url to the browser. No keys in the frontend.
<script src="https://validento.com/embed.js"></script>
<button id="verify">Verify my documents</button>
<script>
document.getElementById('verify').onclick = () => {
Validento.open({
url: EMBED_URL_FROM_YOUR_SERVER,
onReady: () => console.log('page loaded'),
onComplete: (e) => console.log('submitted', e.screening_id), // result follows by webhook
onClose: () => console.log('closed'),
closeDelay: 2500,
});
};
// Or inline: Validento.mount('#verify-box', { url: EMBED_URL, onComplete })
</script>
/v1/verification-linkslinks:readFilters: status (pending/completed/expired/canceled), reference, purpose + pagination.
/v1/verification-links/{id}links:read/v1/verification-links/{id}/cancellinks:writeWe send you a signed POST when something changes. Each environment has its own endpoints.
| Event | When |
|---|---|
screening.created | Screening created (status processing). |
screening.completed | Result available: score, decision, result. |
screening.failed | Could not be completed (not charged). |
screening.deleted | Erased (GDPR) via API or dashboard. |
link.created | Verification link created. |
link.completed | The person submitted documents; data.screening_id. |
link.canceled | Link canceled. |
link.expired | The link expired unused. |
ping | Test from the dashboard or the API. |
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, "…": "…" }
}
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.
import express from 'express';
import crypto from 'node:crypto';
const app = express();
app.post('/webhooks/validento', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('Validento-Signature') || '';
const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const expected = crypto.createHmac('sha256', process.env.VALIDENTO_WHSEC)
.update(`${t}.${req.body}`).digest('hex');
const ok = v1 && v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
if (!ok || Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400);
const event = JSON.parse(req.body);
res.sendStatus(200); // answer fast, work after
queue.add(event); // dedupe on event.id
});
// Or with the SDK: const event = await Validento.webhooks.verify(rawBody, header, secret);import hmac, hashlib, time, json
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/webhooks/validento")
def validento_webhook():
raw = request.get_data() # bytes, exactly as received
parts = dict(p.split("=", 1) for p in request.headers.get("Validento-Signature", "").split(","))
expected = hmac.new(WHSEC.encode(), f"{parts.get('t')}.".encode() + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")) or abs(time.time() - int(parts.get("t", 0))) > 300:
abort(400)
event = json.loads(raw)
enqueue(event) # dedupe on event["id"]
return "", 200<?php
$raw = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_VALIDENTO_SIGNATURE'] ?? ''), $sig);
$expected = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $raw, getenv('VALIDENTO_WHSEC'));
if (!hash_equals($expected, $sig['v1'] ?? '') || abs(time() - (int)($sig['t'] ?? 0)) > 300) {
http_response_code(400); exit;
}
$event = json_decode($raw, true);
http_response_code(200);
// process $event (dedupe on $event['id'])# 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
}
event.id and, if state matters, fetch the object with GET.GET /v1/events and resend with POST /v1/events/{id}/resend./v1/webhook-endpointswebhooks:read/v1/webhook-endpointswebhooks:write/v1/webhook-endpoints/{id}webhooks:write/v1/webhook-endpoints/{id}webhooks:write/v1/webhook-endpoints/{id}/roll-secretwebhooks:write/v1/webhook-endpoints/{id}/testwebhooks:writecurl 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)const ep = await vd.webhookEndpoints.create({ url: 'https://api.yourapp.com/webhooks/validento', events: ['screening.completed', 'screening.failed'] });
console.log(ep.secret); // store it now
console.log(await vd.webhookEndpoints.test(ep.id)); // { delivered: true, status: 200 }ep = requests.post("https://api.validento.com/v1/webhook-endpoints", headers=H, json={"url": "https://api.yourapp.com/webhooks/validento", "events": ["screening.completed"]}).json()
print(ep["secret"])$ep = validento("POST", "/v1/webhook-endpoints", ["url" => "https://api.yourapp.com/webhooks/validento", "events" => ["screening.completed"]]);
echo $ep["secret"];
Every event is kept for 30 days, with or without endpoints. Use them to reconcile, recover from outages, or build without webhooks.
/v1/events?type=screening.completed,link.completedevents:read/v1/events/{id}events:read/v1/events/{id}/resendwebhooks:writeresend delivers again to every subscribed endpoint, or to one with { "endpoint_id": "…" } (even if not subscribed).
// Catch up after downtime: everything since the last event you processed
for await (const ev of vd.events.listAll({ type: ['screening.completed', 'screening.failed'], created: { gte: lastSeenUnix } })) {
await handle(ev); // same handler as your webhook
}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
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.
/v1/settingssettings:read/v1/settingssettings:writedecision.approve_min / review_min 1–100Decision thresholds (default 70 / 45).
branding.display_name / logo_url / primary_color / support_email / privacy_url stringYour brand on the upload page, emails and reports.
branding.hide_powered_by booleanHides “powered by Validento”.
link_defaults.* objectpurpose, country, expiry_days, documents_requested, redirect_url, email_applicant, language.
result_format.summary / deviations / fields / income / breakdown / applicant booleanWhich parts you receive by API and webhooks.
result_format.score_scale 100 | 10 | gradeFormat 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" } }'await vd.settings.update({ decision: { approve_min: 75 }, result_format: { fields: false, score_scale: '10' } });
/v1/usageusage:readCurrent period (20th to 19th), screenings, graduated amount, minimum, balance (prepaid) or credit limit (invoice).
/v1/invoicesusage:readMonthly invoices and statements with a link to the Stripe invoice and its PDF.
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
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.
# 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
DELETE /v1/screenings/{id}.consent_confirmed records the basis of your check.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.