Constaia
Integrations

Bun

Use Constaia with Bun and no framework: Bun.serve with upload and webhook routes, Bun.file for scripts and the timeout settings you need.

Cette page n'est pas encore traduite dans votre langue. Voici la version anglaise.

You will build with Bun and the SDK, no framework:

  1. A script that analyses a local file with Bun.file.
  2. A Bun.serve server with POST /api/verify-dni (upload) and POST /webhooks/constaia (signed events).

The SDK uses fetch, FormData and WebCrypto, which Bun implements natively. If you prefer a router, the Hono guide works the same on Bun.

Install

bun add @constaia/sdk

Requirements: Bun 1.2.3 or later (for the routes option of Bun.serve).

Environment variables

.env
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...

Bun loads .env automatically and exposes it in process.env, so new Constaia() finds the key on its own. Create it in the dashboard → API keys; start with a ck_test_ key (Test mode).

1. Script

scripts/verify.ts
import { Constaia, ConstaiaError } from "@constaia/sdk";

const constaia = new Constaia();
const path = Bun.argv[2] ?? "dni_valid.jpg";

try {
  const analysis = await constaia.analyze(Bun.file(path), {
    filename: path.split("/").pop(),
    expect: "es_dni",
    checks: { notExpired: true },
    language: "en",
  });
  console.log(analysis.verdict?.status, analysis.verdict?.reasons.map((r) => r.message));
  console.log(analysis.fields.document_number?.value);
} catch (error) {
  if (!(error instanceof ConstaiaError)) throw error;
  console.error(error.name, error.code, error.message, error.requestId);
  process.exitCode = 1;
}
bun run scripts/verify.ts dni_valid.jpg
valid [ "The document is Spanish ID card (DNI).", "Valid until 12/03/2031." ]
12345678Z

Bun.file is a Blob, but its name is the full path: pass filename with the file name, which decides the response in test mode.

2. Server

Shared helpers

src/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;
  }
}

Server

src/server.ts
import { Constaia, WebhookVerificationError } from "@constaia/sdk";
import { alreadyProcessed, errorResponse, handleEvent, markProcessed, publicResult } from "./constaia.ts";

const constaia = new Constaia();
const webhookSecret = Bun.env.CONSTAIA_WEBHOOK_SECRET!;

const server = Bun.serve({
  port: Number(Bun.env.PORT ?? 3000),
  // By default Bun closes idle connections after 10 s; an analysis can take up to 30 s.
  idleTimeout: 120,
  maxRequestBodySize: 21 * 1024 * 1024,

  routes: {
    // Put your authentication here: only signed-in users should spend credits.
    "/api/verify-dni": {
      POST: async (req) => {
        const form = await req.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 });
        }
        // Even better: the authenticated user's name, not the form's.
        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",
          });
          // Store analysis.id and analysis.verdict in your database.
          return Response.json(publicResult(analysis), { status: analysis.status === "completed" ? 200 : 202 });
        } catch (error) {
          return errorResponse(error);
        }
      },
    },

    "/webhooks/constaia": {
      POST: async (req) => {
        const rawBody = await req.text();
        let event;
        try {
          event = await constaia.webhooks.verify(rawBody, req.headers, webhookSecret);
        } catch (error) {
          if (error instanceof WebhookVerificationError) return new Response("Invalid signature", { status: 400 });
          throw error;
        }

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

  fetch() {
    return new Response("Not found", { status: 404 });
  },
});

console.log(`http://localhost:${server.port}`);
bun run src/server.ts

Key points:

  • idleTimeout. The most often forgotten setting: with the default, Bun drops the connection while you wait for Constaia and the browser gets an error.
  • maxRequestBodySize. Bun rejects larger bodies with 413 before they reach your code.
  • Raw body. req.text() returns the exact body Constaia signs; don't use req.json() in the webhook.
  • The server decides expect and checks, never the client. 202 if the analysis is still queued or processing after 30 s.

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

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.

For the webhook, sign a body with signWebhook and post it:

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, Bun.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.
  • maxRequestBodySize of at least 20 MB (plus multipart overhead) and the same in the proxy.
  • idleTimeout of 60 s or more in Bun and matching timeouts in the proxy.
  • PDFs over 30 pages or high volume: async: true + webhook, or batches.
  • Persistent webhook dedupe (unique constraint on webhook-id).
  • ck_live_ key only in the server environment.
  • Alert on InsufficientCreditsError and the credits.low event.

Next steps

Sur cette page