Crear enlaces por API y recibir los resultados
Crea un enlace de verificación desde tu backend, recibe los resultados en un callback firmado, envía un resumen por email y devuelve a la persona a tu web con el estado.
Con un enlace de verificación la persona sube sus documentos desde el móvil en una página alojada por Constaia. Esta guía cubre el camino de vuelta: cómo te llegan los resultados a tu sistema sin tener que consultar la API.
Tu servidor Constaia
─────────── ────────
1. POST /v1/verification-links ─────────────────────────▶ url + qr_svg + dossier_id
{ documents, callback_url, results_email,
redirect_url, redirect_with_status }
2. Envías url a la persona ──────────────────────────▶ la persona sube sus documentos
(cada subida es un análisis)
3. Vuelve a redirect_url?link_id=vl_…&status=completed
4. callback_url ◀── POST firmado verification_link.completed
{ data: enlace + results[] + dossier }
5. results_email ◀── resumen sin datos de los documentosPaso 1: crear el enlace
Todos los campos nuevos son opcionales y se combinan:
| Campo | Para qué |
|---|---|
callback_url | Tu URL https:// que recibe el resultado firmado al completarse (o caducar) este enlace. No necesitas crear un endpoint de webhook. |
include_results | true por defecto: el callback trae el análisis completo de cada documento. false: solo el enlace y el veredicto del expediente. |
results_email | Un email (por ejemplo, el de tu secretaría) que recibe un resumen al completarse. |
redirect_url + redirect_with_status | A dónde vuelve la persona al terminar, con ?link_id=vl_…&status=… si activas 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 o NIE", "expect": ["es_dni", "es_nie"], "checks": { "min_age_years": 18 } },
{ "key": "receipt", "label": "Justificante de pago", "expect": "payment_receipt", "checks": { "expected_amount": 45 } }
],
"reference": "inscripcion-4821",
"metadata": { "registration_id": "4821" },
"callback_url": "https://example.com/constaia/callback",
"include_results": true,
"results_email": "secretaria@example.com",
"redirect_url": "https://example.com/inscripcion/4821",
"redirect_with_status": true
}'La respuesta trae url (envíasela a la persona por tu canal, o pide a Constaia que lo haga con notify_email),
qr_svg y dossier_id. Guarda id (vl_…) junto a tu registro: es la clave para cruzar el callback.
Documentos sin expect
Si un documento no lleva expect (ni lo aporta la plantilla), desde v1.2 su análisis tiene igualmente veredicto,
calculado contra el tipo detectado (verdict.basis: "detected"), salvo que el documento no se reconozca (generic).
Pide expect siempre que sepas qué documento quieres: así un documento de otro tipo sale invalid. Ver
Veredictos.
Paso 2: recibir el callback
Cuando el enlace pasa a completed (todos los documentos terminados) o a expired, Constaia hace un POST a
callback_url con el mismo cuerpo que el webhook global verification_link.completed o verification_link.expired.
Si además tienes endpoints de webhook suscritos a esos eventos, también lo reciben.
Con qué secreto se firma
El callback sigue Standard Webhooks, igual que los webhooks. El secreto se elige en cada envío:
- Si la cuenta tiene endpoints de webhook activos en el mismo modo que el enlace (test o live), el secreto del más antiguo de ellos.
- Si no tiene ninguno en ese modo, el secreto de callbacks de la cuenta (
whsec_…). Lo ves en el panel, en Desarrolladores → Webhooks, tarjeta Secreto de los callbacks de enlaces, junto con qué secreto firma en cada modo, y puedes rotarlo (propietarios y administradores). Más detalles en Webhooks.
Si firma un endpoint, el secreto es el whsec_… que guardaste al crearlo (el mismo con el que verificas sus
webhooks).
Como se resuelve en cada envío, si creas tu primer endpoint o rotas el secreto de callbacks, los envíos y reintentos siguientes usan el secreto nuevo.
Verificar la firma
Lee el cuerpo en crudo y verifícalo antes de parsearlo. Los SDK usan el mismo verificador que para los 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);
// Deduplica por webhook-id y procesa en segundo plano
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 ?? "sin subir");
}
}
});
app.listen(3000);constaia.webhooks.verify(...) hace exactamente lo mismo: el formato es idéntico.
Sin SDK, usa cualquiera de las implementaciones de Verificar sin SDK: el algoritmo es el mismo.
Entregas y reintentos
- Cabeceras
webhook-id(msg_…, el mismo en todos los reintentos: úsalo para deduplicar),webhook-timestampywebhook-signature. - Cuenta como entregado cualquier
2xxen menos de 15 segundos. Las redirecciones no se siguen. - Si falla, se reintenta con el mismo calendario que los webhooks: 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h, 12 h, 12 h, 10 h y 10 h (unos 3 días). Después la entrega queda como fallida.
- En el panel, el detalle del enlace muestra las entregas del callback con su código de respuesta, y puedes reenviar una al momento.
El cuerpo de verification_link.completed
data es el objeto enlace (sin qr_svg) más results y dossier. results trae un elemento por documento pedido,
en orden, con el análisis que cuenta para ese documento (el último, o el último completado si el último falló)
tal como se guardó: si la plantilla usa mask_fields, esos campos llegan enmascarados; nunca trae file_url. Si
un documento no se subió, analysis es null. Con keep_results: false los datos extraídos no se guardan, así
que tampoco llegan en results.
{
"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": "inscripcion-4821",
"metadata": { "registration_id": "4821" },
"documents": [
{ "key": "id_card", "label": "DNI o NIE", "status": "completed", "analysis_id": "an_01J9Z8Q3K4M5N6P7Q8R9S0T1V4", "attempts": 1, "verdict_status": "valid", "final_status": "valid", "warnings": [], "…": "…" },
{ "key": "receipt", "label": "Justificante de pago", "status": "completed", "analysis_id": "an_01J9Z8Q3K4M5N6P7Q8R9S0T1V5", "attempts": 2, "verdict_status": "review", "final_status": null, "warnings": [], "…": "…" }
],
"dossier_id": "dos_01J9Z8Q3K4M5N6P7Q8R9S0T1V3",
"redirect_url": "https://example.com/inscripcion/4821",
"redirect_with_status": true,
"callback_url": "https://example.com/constaia/callback",
"include_results": true,
"results_email": "secretaria@example.com",
"results_email_sent_at": null,
"locale": "es",
"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 o NIE",
"analysis": {
"id": "an_01J9Z8Q3K4M5N6P7Q8R9S0T1V4",
"object": "analysis",
"status": "completed",
"livemode": false,
"document": { "type": "es_dni", "label": "DNI (España)", "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": "El documento es DNI (España)." },
{ "code": "not_expired", "severity": "info", "message": "Vigente hasta el 12/03/2031." },
{ "code": "age", "severity": "info", "message": "El titular tiene 36 años." }
],
"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": "La letra del documento 12****78Z es correcta." }],
"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": "Justificante de pago",
"analysis": {
"id": "an_01J9Z8Q3K4M5N6P7Q8R9S0T1V5",
"object": "analysis",
"status": "completed",
"document": { "type": "payment_receipt", "label": "Justificante de pago / transferencia", "confidence": 0.91, "side": null, "country": "ESP" },
"verdict": {
"basis": "expected",
"expected": ["payment_receipt"],
"match": true,
"status": "review",
"reasons": [
{ "code": "type_match", "severity": "info", "message": "El documento es Justificante de pago / transferencia." },
{ "code": "expected_amount", "severity": "info", "message": "amount coincide con lo esperado." },
{ "code": "low_quality", "severity": "warning", "message": "La calidad de la imagen es insuficiente (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": "«Justificante de pago» necesita una revisión manual." }]
}
}
}
}En este ejemplo la plantilla enmascara document_number (mask_fields), así que el número llega como 12****78Z
también en los mensajes. verification_link.expired tiene la misma forma, con lo que la persona llegó a subir.
Qué mirar para decidir:
data.dossier.verdict.status:complete_valid(todo correcto),incomplete(falta algo),invalidoreview.results[].analysis.verdict.final_status: el resultado de cada documento;nullmientras espera una revisión humana. Cuando alguien la decida recibirásanalysis.reviewedsi tienes un endpoint suscrito.
Paso 3: email de resultados
Con results_email, al completarse el enlace Constaia envía un resumen en el idioma del enlace con:
- El estado del enlace y el veredicto global del expediente.
- Una línea por documento: su etiqueta, el resultado (Válido, No válido, A revisar, Sin enviar o No se pudo analizar)
y el tipo detectado, con los motivos de severidad
warningoerror. - Un botón al detalle del enlace en el panel.
El email no lleva datos de los documentos: los valores extraídos se enmascaran en los motivos y cualquier cadena
larga con dígitos (números de documento, IBAN…) también. Solo se envía al completarse, no al caducar. Cuando sale,
el enlace registra el evento results_sent y rellena results_email_sent_at.
Paso 4: la vuelta a tu web
Con redirect_url, la pantalla final de la página muestra un botón para volver. Si además envías
redirect_with_status: true, la URL lleva el id del enlace y su estado:
https://example.com/inscripcion/4821?link_id=vl_01J9Z8Q3K4M5N6P7Q8R9S0T1V2&status=completed| Parámetro | Valor |
|---|---|
link_id | Id del enlace (vl_…). |
status | Estado del enlace al mostrarse la pantalla final: normalmente completed, o in_progress si algún documento aún se estaba analizando. |
Los parámetros que ya tuviera tu URL se conservan. Úsalos para enseñar el mensaje adecuado, no como prueba:
cualquiera puede escribir esa URL. La fuente fiable es el callback firmado o
GET /v1/verification-links/{id}.
Probar en modo test
Crea el enlace con una clave ck_test_: las subidas usan el modo test y no cuestan créditos.
El callback se firma con el secreto de tu endpoint de test más antiguo o, si no tienes ninguno, con el secreto de
callbacks de la cuenta. callback_url tiene que ser https://: para recibirlo en tu máquina, expón tu servidor local
con un túnel https.
Siguientes pasos
Revisión humana desde tu backend
Lleva la revisión humana a tu propia aplicación con la API. Recibe los pendientes, descarga el documento original, registra la decisión y aplica el resultado final.
Integración solo frontend
Sube documentos desde el navegador directamente a Constaia con una clave publicable pk_ y una sesión creada por tu backend: sin ruta de subida propia y sin exponer la clave secreta. Widget, fetch y SDK.