Create links via API and receive the results
Create a verification link from your backend, receive the results in a signed callback, send a summary by email and bring the person back to your website with the status.
With a verification link the person uploads their documents from their phone on a page hosted by Constaia. This guide covers the way back: how the results reach your system without having to poll the API.
Your server Constaia
─────────── ────────
1. POST /v1/verification-links ─────────────────────────▶ url + qr_svg + dossier_id
{ documents, callback_url, results_email,
redirect_url, redirect_with_status }
2. You send url to the person ────────────────────────▶ the person uploads their documents
(each upload is an analysis)
3. Back to redirect_url?link_id=vl_…&status=completed
4. callback_url ◀── signed POST verification_link.completed
{ data: link + results[] + dossier }
5. results_email ◀── summary without document dataStep 1: create the link
All the new fields are optional and can be combined:
| Field | What for |
|---|---|
callback_url | Your https:// URL that receives the signed result when this link is completed (or expires). You don't need to create a webhook endpoint. |
include_results | true by default: the callback carries the full analysis of each document. false: only the link and the dossier verdict. |
results_email | An email address (for example, your office's) that receives a summary on completion. |
redirect_url + redirect_with_status | Where the person goes back to when they finish, with ?link_id=vl_…&status=… if you enable redirect_with_status. |
curl https://api.constaia.com/v1/verification-links \
-H "Authorization: Bearer $CONSTAIA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"documents": [
{ "key": "id_card", "label": "DNI or NIE", "expect": ["es_dni", "es_nie"], "checks": { "min_age_years": 18 } },
{ "key": "receipt", "label": "Payment receipt", "expect": "payment_receipt", "checks": { "expected_amount": 45 } }
],
"reference": "registration-4821",
"metadata": { "registration_id": "4821" },
"callback_url": "https://example.com/constaia/callback",
"include_results": true,
"results_email": "office@example.com",
"redirect_url": "https://example.com/registration/4821",
"redirect_with_status": true
}'The response includes url (send it to the person through your own channel, or have Constaia do it with
notify_email), qr_svg and dossier_id. Store id (vl_…) alongside your record: it's the key to match the
callback.
Documents without expect
If a document has no expect (and the template doesn't provide one), since v1.2 its analysis still gets a verdict,
computed against the detected type (verdict.basis: "detected"), unless the document isn't recognized (generic).
Set expect whenever you know which document you want: that way a document of another type comes out invalid. See
Verdicts.
Step 2: receive the callback
When the link becomes completed (all documents finished) or expired, Constaia sends a POST to callback_url
with the same body as the global verification_link.completed or verification_link.expired webhook. If you also
have webhook endpoints subscribed to those events, they receive it too.
Which secret signs it
The callback follows Standard Webhooks, just like webhooks. The secret is chosen on each delivery:
- If the account has active webhook endpoints in the same mode as the link (test or live), the secret of the oldest of them.
- If it has none in that mode, the account's callback secret (
whsec_…). You can see it in the dashboard, under Developers → Webhooks, in the Verification link callback secret card, together with which secret signs in each mode, and you can rotate it (owners and admins). More details in Webhooks.
If an endpoint signs it, the secret is the whsec_… you saved when you created it (the same one you use to verify
its webhooks).
Because it's resolved on each delivery, if you create your first endpoint or rotate the callback secret, the following deliveries and retries use the new secret.
Verify the signature
Read the raw body and verify it before parsing it. The SDKs use the same verifier as for webhooks.
import express from "express";
import { Constaia, WebhookVerificationError } from "@constaia/sdk";
const app = express();
const constaia = new Constaia();
const secret = process.env.CONSTAIA_CALLBACK_SECRET!;
app.post("/constaia/callback", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = await constaia.verificationLinks.verifyCallback(req.body, req.headers, secret);
} catch (err) {
return res.sendStatus(err instanceof WebhookVerificationError ? 400 : 500);
}
res.sendStatus(204);
// Deduplicate by webhook-id and process in the background
const link = event.data;
if (event.type === "verification_link.completed") {
console.log(link.id, link.reference, link.dossier?.verdict.status);
for (const r of link.results ?? []) {
console.log(r.document_key, r.analysis?.verdict?.final_status ?? r.analysis?.verdict?.status ?? "not uploaded");
}
}
});
app.listen(3000);constaia.webhooks.verify(...) does exactly the same: the format is identical.
Without an SDK, use any of the implementations in Verify without an SDK: the algorithm is the same.
Deliveries and retries
- Headers
webhook-id(msg_…, the same across all retries: use it to deduplicate),webhook-timestampandwebhook-signature. - Any
2xxwithin 15 seconds counts as delivered. Redirects are not followed. - If it fails, it's retried on the same schedule as webhooks: 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h, 12 h, 12 h, 10 h and 10 h (about 3 days). After that the delivery is marked as failed.
- In the dashboard, the link detail shows the callback deliveries with their response code, and you can resend one right away.
The verification_link.completed body
data is the link object (without qr_svg) plus results and dossier. results has one item per requested
document, in order, with the analysis that counts for that document (the latest one, or the latest completed one if
the latest failed) as it was stored: if the template uses mask_fields, those fields arrive masked; it never
includes file_url. If a document wasn't uploaded, analysis is null. With keep_results: false the extracted
data isn't stored, so it doesn't arrive in results either.
{
"type": "verification_link.completed",
"created_at": "2026-09-30T10:14:03.201Z",
"data": {
"id": "vl_01J9Z8Q3K4M5N6P7Q8R9S0T1V2",
"object": "verification_link",
"url": "https://app.constaia.com/v/3kqX…",
"status": "completed",
"livemode": false,
"template": "tpl_01J9Z8Q3K4M5N6P7Q8R9S0T1V9",
"reference": "registration-4821",
"metadata": { "registration_id": "4821" },
"documents": [
{ "key": "id_card", "label": "DNI or NIE", "status": "completed", "analysis_id": "an_01J9Z8Q3K4M5N6P7Q8R9S0T1V4", "attempts": 1, "verdict_status": "valid", "final_status": "valid", "warnings": [], "…": "…" },
{ "key": "receipt", "label": "Payment receipt", "status": "completed", "analysis_id": "an_01J9Z8Q3K4M5N6P7Q8R9S0T1V5", "attempts": 2, "verdict_status": "review", "final_status": null, "warnings": [], "…": "…" }
],
"dossier_id": "dos_01J9Z8Q3K4M5N6P7Q8R9S0T1V3",
"redirect_url": "https://example.com/registration/4821",
"redirect_with_status": true,
"callback_url": "https://example.com/constaia/callback",
"include_results": true,
"results_email": "office@example.com",
"results_email_sent_at": null,
"locale": "en",
"events": [
{ "type": "created", "at": "2026-09-30T10:00:00Z" },
{ "type": "opened", "at": "2026-09-30T10:09:12Z" },
{ "type": "consent_accepted", "at": "2026-09-30T10:09:40Z" },
{ "type": "document_uploaded", "at": "2026-09-30T10:10:05Z", "document_key": "id_card" },
{ "type": "document_accepted", "at": "2026-09-30T10:10:08Z", "document_key": "id_card" },
{ "type": "…", "at": "…" },
{ "type": "completed", "at": "2026-09-30T10:14:03Z" }
],
"created_at": "2026-09-30T10:00:00Z",
"completed_at": "2026-09-30T10:14:03Z",
"cancelled_at": null,
"results": [
{
"document_key": "id_card",
"label": "DNI or NIE",
"analysis": {
"id": "an_01J9Z8Q3K4M5N6P7Q8R9S0T1V4",
"object": "analysis",
"status": "completed",
"livemode": false,
"document": { "type": "es_dni", "label": "Spanish ID card (DNI)", "confidence": 0.97, "side": "both", "country": "ESP" },
"verdict": {
"basis": "expected",
"expected": ["es_dni", "es_nie"],
"match": true,
"status": "valid",
"reasons": [
{ "code": "type_match", "severity": "info", "message": "The document is Spanish ID card (DNI)." },
{ "code": "not_expired", "severity": "info", "message": "Valid until 12/03/2031." },
{ "code": "age", "severity": "info", "message": "The holder is 36 years old." }
],
"final_status": "valid",
"reviewed_by": null,
"reviewed_at": null
},
"fields": {
"document_number": { "value": "12****78Z", "confidence": 0.99, "validated": true, "source": { "page": 1, "bbox": [0.61, 0.12, 0.83, 0.16] } },
"full_name": { "value": "María García López", "confidence": 0.98, "validated": null, "source": { "page": 1, "bbox": [0.38, 0.22, 0.8, 0.27] } }
},
"checks": [{ "code": "nif_check_digit", "passed": true, "message": "The check letter of 12****78Z is correct." }],
"warnings": [],
"storage": { "mode": "review", "kept": false, "reason": null, "expires_at": null, "file_deleted_at": "2026-09-30T10:10:08Z" },
"metadata": { "registration_id": "4821" },
"verification_link_id": "vl_01J9Z8Q3K4M5N6P7Q8R9S0T1V2",
"dossier_id": "dos_01J9Z8Q3K4M5N6P7Q8R9S0T1V3",
"document_key": "id_card"
}
},
{
"document_key": "receipt",
"label": "Payment receipt",
"analysis": {
"id": "an_01J9Z8Q3K4M5N6P7Q8R9S0T1V5",
"object": "analysis",
"status": "completed",
"document": { "type": "payment_receipt", "label": "Payment / bank transfer receipt", "confidence": 0.91, "side": null, "country": "ESP" },
"verdict": {
"basis": "expected",
"expected": ["payment_receipt"],
"match": true,
"status": "review",
"reasons": [
{ "code": "type_match", "severity": "info", "message": "The document is Payment / bank transfer receipt." },
{ "code": "expected_amount", "severity": "info", "message": "amount matches the expected value." },
{ "code": "low_quality", "severity": "warning", "message": "Image quality is insufficient (glare)." }
],
"final_status": null,
"reviewed_by": null,
"reviewed_at": null
},
"review": { "status": "pending", "decision": null, "…": "…" },
"storage": { "mode": "review", "kept": true, "reason": "pending_review", "…": "…" },
"…": "…"
}
}
],
"dossier": {
"id": "dos_01J9Z8Q3K4M5N6P7Q8R9S0T1V3",
"verdict": {
"status": "review",
"reasons": [{ "code": "requirement_review", "severity": "warning", "message": "“Payment receipt” needs a manual review." }]
}
}
}
}In this example the template masks document_number (mask_fields), so the number arrives as 12****78Z in the
messages too. verification_link.expired has the same shape, with whatever the person managed to upload.
What to look at to decide:
data.dossier.verdict.status:complete_valid(all correct),incomplete(something is missing),invalidorreview.results[].analysis.verdict.final_status: the result of each document;nullwhile it waits for a human review. When someone decides it you'll receiveanalysis.reviewedif you have a subscribed endpoint.
Step 3: results email
With results_email, when the link is completed Constaia sends a summary in the link's language with:
- The link status and the overall dossier verdict.
- One line per document: its label, the result (Valid, Invalid, Needs review, Not submitted or Could not be
analysed) and the detected type, with the reasons of severity
warningorerror. - A button to the link detail in the dashboard.
The email carries no document data: extracted values are masked in the reasons, and so is any long string with
digits (document numbers, IBANs…). It's only sent on completion, not on expiry. When it goes out, the link records
the results_sent event and fills in results_email_sent_at.
Step 4: back to your website
With redirect_url, the final screen of the page shows a button to go back. If you also send
redirect_with_status: true, the URL carries the link id and its status:
https://example.com/registration/4821?link_id=vl_01J9Z8Q3K4M5N6P7Q8R9S0T1V2&status=completed| Parameter | Value |
|---|---|
link_id | Link id (vl_…). |
status | Link status when the final screen is shown: usually completed, or in_progress if a document was still being analyzed. |
Any parameters your URL already had are kept. Use them to show the right message, not as proof: anyone can type
that URL. The reliable source is the signed callback or
GET /v1/verification-links/{id}.
Test it in test mode
Create the link with a ck_test_ key: uploads use test mode and cost no credits. The
callback is signed with the secret of your oldest test endpoint or, if you have none, with the account's callback
secret. callback_url has to be https://: to receive it on your machine, expose your local server through an https
tunnel.
Next steps
Human review from your backend
Bring human review into your own application with the API. Get pending reviews, download the original document, record the decision and apply the final result.
Frontend-only integration
Upload documents from the browser straight to Constaia with a pk_ publishable key and a session created by your backend: no upload route of your own and no exposed secret key. Widget, fetch and SDK.