Constaia
Guías por caso

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 documentos

Paso 1: crear el enlace

Todos los campos nuevos son opcionales y se combinan:

CampoPara qué
callback_urlTu URL https:// que recibe el resultado firmado al completarse (o caducar) este enlace. No necesitas crear un endpoint de webhook.
include_resultstrue por defecto: el callback trae el análisis completo de cada documento. false: solo el enlace y el veredicto del expediente.
results_emailUn email (por ejemplo, el de tu secretaría) que recibe un resumen al completarse.
redirect_url + redirect_with_statusA dónde vuelve la persona al terminar, con ?link_id=vl_…&status=… si activas 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 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:

  1. 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.
  2. 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.

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);

  // 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-timestamp y webhook-signature.
  • Cuenta como entregado cualquier 2xx en 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.

POST a callback_url (recortado)
{
  "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), invalid o review.
  • results[].analysis.verdict.final_status: el resultado de cada documento; null mientras espera una revisión humana. Cuando alguien la decida recibirás analysis.reviewed si 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 warning o error.
  • 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ámetroValor
link_idId del enlace (vl_…).
statusEstado 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

En esta página