Constaia
Integrations

Edge Functions

Use Constaia in Vercel Edge Functions and Netlify Edge Functions: document uploads, a WebCrypto-verified webhook and runtime limits to check.

The JavaScript SDK runs on edge runtimes because it only uses fetch, FormData and WebCrypto. This guide covers:

  • Vercel Edge Functions (a function with runtime: "edge").
  • Netlify Edge Functions (Deno).

In both cases you create two functions: POST /api/verify-dni to upload the document and POST /webhooks/constaia for signed events.

Check your platform limits

Edge runtimes limit request body size, time to first response and memory, and those limits vary by plan. A synchronous analysis can take up to 30 s and files can be up to 20 MB. Check your platform's documentation before going to production. If the limits fall short, use async: true with webhooks, upload the file to storage and pass a signed URL as fileUrl, or use a Node.js runtime function (for example, the Next.js guide).

Install

npm i @constaia/sdk

On Netlify, also install the types: npm i -D @netlify/edge-functions.

Environment variables

Set CONSTAIA_API_KEY and CONSTAIA_WEBHOOK_SECRET in the project's environment variables (Vercel or Netlify dashboard), never in code.

  • They are server-only: don't use prefixes that expose them to the browser (NEXT_PUBLIC_, VITE_…).
  • On Vercel read them with process.env; on Netlify, with Netlify.env.get().
  • fromPath (from @constaia/sdk/node) is not available: there is no file system on the edge. Use the File from FormData, { fileUrl } or { base64, filename }.

Shared helpers

lib/constaia.ts
import {
  AuthenticationError,
  ConstaiaError,
  InsufficientCreditsError,
  InvalidRequestError,
  PermissionError,
  RateLimitError,
  type Analysis,
  type WebhookEvent,
} from "@constaia/sdk";

// What you send back to the browser (and what the widget renders): no extracted fields.
export function publicResult(analysis: Analysis) {
  const { id, object, status, document, verdict, warnings } = analysis;
  return { id, object, status, document, verdict, warnings };
}

const httpError = (status: number, code: string, message: string, retryAfter?: number) =>
  Response.json(
    { error: { code, message } },
    { status, headers: retryAfter ? { "Retry-After": String(retryAfter) } : undefined },
  );

export function errorResponse(error: unknown): Response {
  if (error instanceof InvalidRequestError) {
    // Unreadable file, unsupported format, too many pages…: the user can fix it.
    return httpError(error.status === 413 ? 413 : 422, error.code ?? "invalid_request", error.message);
  }
  if (error instanceof RateLimitError) {
    const wait = Math.ceil(error.retryAfter ?? 1);
    return httpError(429, "rate_limited", "Too many requests. Try again in a few seconds.", wait);
  }
  if (error instanceof InsufficientCreditsError) {
    console.error("[constaia] Out of credits. Top up at https://app.constaia.com", error.requestId);
    return httpError(503, "unavailable", "Document validation is temporarily unavailable.");
  }
  if (error instanceof AuthenticationError || error instanceof PermissionError) {
    console.error("[constaia] Check CONSTAIA_API_KEY", error.code, error.requestId);
    return httpError(500, "misconfigured", "Server configuration error.");
  }
  if (error instanceof ConstaiaError) {
    // APIError (5xx), APIConnectionError, APITimeoutError
    console.error("[constaia]", error.name, error.code, error.requestId);
    return httpError(502, "upstream_error", "The document could not be analysed. Please try again.");
  }
  console.error(error);
  return httpError(500, "internal_error", "Internal error.");
}

// Dedupe by webhook-id. In production, use a table with a unique key.
const processed = new Set<string>();
export const alreadyProcessed = (webhookId: string) => processed.has(webhookId);
export const markProcessed = (webhookId: string) => void processed.add(webhookId);

export async function handleEvent(event: WebhookEvent) {
  switch (event.type) {
    case "analysis.completed":
      // event.data is the full analysis (with fields). Store it by event.data.id.
      console.log("analysis.completed", event.data.id, event.data.verdict?.status);
      break;
    case "analysis.review_required":
      console.log("Needs manual review", event.data.id);
      break;
    case "analysis.failed":
      console.warn("analysis.failed", event.data.id, event.data.error?.code);
      break;
    case "credits.low":
      console.warn("Credits running low: top up at https://app.constaia.com");
      break;
  }
}

In-memory dedupe does not work on the edge: each request may run in a different instance. Replace alreadyProcessed and markProcessed with a shared store (your database, a KV store or Netlify Blobs).

Upload route

api/verify-dni.ts
import { Constaia } from "@constaia/sdk";
import { errorResponse, publicResult } from "../lib/constaia";

export const config = { runtime: "edge" };

const constaia = new Constaia({ apiKey: process.env.CONSTAIA_API_KEY });

// Put your authentication here: only signed-in users should spend credits.
export default async function handler(request: Request): Promise<Response> {
  if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });

  const form = await request.formData().catch(() => null);
  const file = form?.get("file");
  if (!(file instanceof File) || file.size === 0) {
    return Response.json({ error: { code: "missing_file", message: "No file received." } }, { status: 400 });
  }
  const fullName = String(form?.get("full_name") ?? "").trim();

  try {
    const analysis = await constaia.analyze(file, {
      expect: "es_dni",
      checks: { notExpired: true, ...(fullName ? { holder: { fullName } } : {}) },
      storage: "none",
      language: "en",
    });
    return Response.json(publicResult(analysis), { status: analysis.status === "completed" ? 200 : 202 });
  } catch (error) {
    return errorResponse(error);
  }
}

In a Next.js project, the same code goes in a route handler (app/api/verify-dni/route.ts) with export const runtime = "edge" and export async function POST(request: Request).

The server sets expect and checks; never take them from the client. If the analysis is still queued or processing after 30 s, the function answers 202 and the result arrives by webhook.

Webhook

api/webhooks/constaia.ts
import { Constaia, WebhookVerificationError } from "@constaia/sdk";
import { alreadyProcessed, handleEvent, markProcessed } from "../../lib/constaia";

export const config = { runtime: "edge" };

const constaia = new Constaia({ apiKey: process.env.CONSTAIA_API_KEY });

export default async function handler(request: Request): Promise<Response> {
  if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });

  const rawBody = await request.text();
  let event;
  try {
    event = await constaia.webhooks.verify(rawBody, request.headers, process.env.CONSTAIA_WEBHOOK_SECRET!);
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response("Invalid signature", { status: 400 });
    throw error;
  }

  const webhookId = request.headers.get("webhook-id")!;
  if (!alreadyProcessed(webhookId)) {
    await handleEvent(event);
    markProcessed(webhookId);
  }
  return new Response(null, { status: 204 });
}

request.text() returns the exact body; verification uses WebCrypto (HMAC-SHA256), available on both runtimes. Don't call request.json() first.

What the browser receives

{
  "id": "an_01J…",
  "object": "analysis",
  "status": "completed",
  "document": { "type": "es_dni", "label": "Spanish ID card (DNI)", "confidence": 0.97, "side": "both", "country": "ESP" },
  "verdict": {
    "expected": ["es_dni"],
    "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": "holder", "severity": "info", "message": "…" }
    ]
  },
  "warnings": []
}

Errors

SDK errorWhenWhat your route returns
InvalidRequestErrorEmpty, unreadable or unsupported file, over 20 MB, too many pages or malformed options (400/409/413/415/422)422 (or 413) with the message, so the user can upload another file
RateLimitErrorYou exceed your key's requests per second (429). The SDK already retries twice honouring Retry-After429 with Retry-After
InsufficientCreditsErrorNo credits left (402)503 to the user and an alert for you: top up in the dashboard
AuthenticationError, PermissionErrorMissing, revoked or wrong key (401/403)500: it is your configuration problem, not the user's
APIError, APIConnectionError, APITimeoutErrorConstaia 5xx or network error, after retries are exhausted502 and a "try again" message

All of them extend ConstaiaError and expose status, code and requestId. Always log the requestId: support will ask for it. Every code is described in Errors.

Test in test mode

Run locally with vercel dev (port 3000) or netlify dev (port 8888) and upload a file:

curl -F "file=@dni_valid.jpg" -F "full_name=María García López" http://localhost:3000/api/verify-dni
Filefull_nameResult
dni_valid.jpgMaría García Lópezvalid
dni_valid.jpgJuan Pérezinvalid: reason holder with severity error
dni_expired.jpg(empty)invalid: reason not_expired with severity error (expired on 15/06/2020)
blurry.jpg(empty)review: reason low_quality and warnings: ["blurry", "low_quality"]
invoice.jpg(empty)invalid: type_mismatch, it is not a DNI

In test mode the response depends on the file name, and the file must be a real JPEG, PNG, WEBP, HEIC or PDF (any renamed image works). It costs no credits and livemode is false. All names are listed in Test mode.

To test the webhook, sign a body with signWebhook from a Node.js script:

scripts/send-test-webhook.ts
import { signWebhook } from "@constaia/sdk";

const body = JSON.stringify({
  type: "analysis.completed",
  created_at: new Date().toISOString(),
  data: { id: "an_test", object: "analysis", status: "completed", verdict: { status: "valid", reasons: [] } },
});
const headers = await signWebhook(body, process.env.CONSTAIA_WEBHOOK_SECRET!);

const res = await fetch("http://localhost:3000/webhooks/constaia", {
  method: "POST",
  headers: { ...headers, "content-type": "application/json" },
  body,
});
console.log(res.status); // 204

Production checklist

  • Authentication and per-user rate limiting on /api/verify-dni: every call spends credits.
  • Your plan's body, duration and memory limits checked in the platform documentation (files up to 20 MB, responses up to 30 s).
  • If they fall short: async: true + webhook, or upload to storage and a signed fileUrl.
  • Webhook dedupe in a shared store, not in memory.
  • ck_live_ key only in server environment variables, with no public prefixes.
  • Alert on InsufficientCreditsError and the credits.low event.

Next steps

On this page