Constaia
Use-case guides

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 data

All the new fields are optional and can be combined:

FieldWhat for
callback_urlYour https:// URL that receives the signed result when this link is completed (or expires). You don't need to create a webhook endpoint.
include_resultstrue by default: the callback carries the full analysis of each document. false: only the link and the dossier verdict.
results_emailAn email address (for example, your office's) that receives a summary on completion.
redirect_url + redirect_with_statusWhere the person goes back to when they finish, with ?link_id=vl_…&status=… if you enable redirect_with_status.
Terminal
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:

  1. If the account has active webhook endpoints in the same mode as the link (test or live), the secret of the oldest of them.
  2. 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.

server.ts
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-timestamp and webhook-signature.
  • Any 2xx within 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.

POST to callback_url (trimmed)
{
  "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), invalid or review.
  • results[].analysis.verdict.final_status: the result of each document; null while it waits for a human review. When someone decides it you'll receive analysis.reviewed if 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 warning or error.
  • 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
ParameterValue
link_idLink id (vl_…).
statusLink 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

On this page