Constaia
Integrations

Hono

Validate documents with Hono 4 and the Constaia SDK on Node.js, Bun or Deno: one app, a body size limit and a verified webhook.

You will write a single Hono 4 app with two routes and run it on Node.js, Bun or Deno without changing the code:

  • POST /api/verify-dni: receives a file, analyses it with Constaia and returns the verdict.
  • POST /webhooks/constaia: verifies the signature on the raw body and drops duplicates.

Hono works with standard Request/Response and the SDK uses fetch and WebCrypto, so they fit together without adapters. The key lives only on the server.

Install

npm i hono @hono/node-server @constaia/sdk
npm i -D typescript tsx

Environment variables

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

Create the key in the dashboard → API keys. The webhook secret is shown only once, when you create the endpoint (Webhooks). Each runtime reads the environment its own way, so the app takes its configuration as a parameter.

Shared helpers

They trim the response the browser sees, map SDK errors to a Response and handle webhook events.

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

The app

src/app.ts
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import { Constaia, WebhookVerificationError } from "@constaia/sdk";
import { alreadyProcessed, errorResponse, handleEvent, markProcessed, publicResult } from "./constaia.ts";

export function createApp(config: { apiKey: string; webhookSecret: string }) {
  const constaia = new Constaia({ apiKey: config.apiKey });
  const app = new Hono();

  // Put your auth and rate-limit middleware here: every call spends credits.
  app.post(
    "/api/verify-dni",
    bodyLimit({
      maxSize: 21 * 1024 * 1024,
      onError: (c) => c.json({ error: { code: "file_too_large", message: "20 MB maximum." } }, 413),
    }),
    async (c) => {
      const body = await c.req.parseBody();
      const file = body.file;
      if (!(file instanceof File) || file.size === 0) {
        return c.json({ error: { code: "missing_file", message: "No file received." } }, 400);
      }
      // Even better: the authenticated user's name, not the form's.
      const fullName = typeof body.full_name === "string" ? body.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 c.json(publicResult(analysis), analysis.status === "completed" ? 200 : 202);
      } catch (error) {
        return errorResponse(error);
      }
    },
  );

  app.post("/webhooks/constaia", async (c) => {
    const rawBody = await c.req.text();
    let event;
    try {
      event = await constaia.webhooks.verify(rawBody, c.req.raw.headers, config.webhookSecret);
    } catch (error) {
      if (error instanceof WebhookVerificationError) return c.text("Invalid signature", 400);
      throw error;
    }

    const webhookId = c.req.header("webhook-id")!;
    if (!alreadyProcessed(webhookId)) {
      await handleEvent(event);
      markProcessed(webhookId);
    }
    return c.body(null, 200);
  });

  return app;
}
  • File keeps the original name, so you don't need to pass filename. In test mode that name decides the response.
  • c.req.text() returns the exact body. Don't call c.req.json() first: the body can only be consumed once and re-serialising it breaks the signature.
  • The server sets expect and checks. The widget sends options with an expect hint that this route ignores on purpose.

Run it on each runtime

src/node.ts
import { serve } from "@hono/node-server";
import { createApp } from "./app.ts";

const app = createApp({
  apiKey: process.env.CONSTAIA_API_KEY!,
  webhookSecret: process.env.CONSTAIA_WEBHOOK_SECRET!,
});

serve({ fetch: app.fetch, port: 3000 });
npx tsx --env-file=.env src/node.ts

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.

Hono lets you test the webhook without a server using app.request:

test/webhook.test.ts
import { signWebhook } from "@constaia/sdk";
import { createApp } from "../src/app.ts";

const secret = process.env.CONSTAIA_WEBHOOK_SECRET!;
const app = createApp({ apiKey: "ck_test_...", webhookSecret: secret });

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

const res = await app.request("/webhooks/constaia", {
  method: "POST",
  headers: { ...headers, "content-type": "application/json" },
  body,
});
console.log(res.status); // 200

Production checklist

  • Authentication and per-user rate limiting on /api/verify-dni.
  • Body limit of at least 20 MB in the app (bodyLimit), the runtime (Bun: maxRequestBodySize) and the proxy.
  • Timeouts of 60 s or more in proxy and runtime (Bun: idleTimeout); a synchronous analysis can take up to 30 s.
  • PDFs over 30 pages or high volume: async: true and the result by webhook.
  • ck_live_ key only in the server environment.
  • Alert on InsufficientCreditsError and the credits.low event.

Next steps

On this page