Fastify
Integrate Constaia with Fastify 5 using @fastify/multipart to validate documents, plus a webhook with an encapsulated raw-body parser.
You will build a Fastify 5 server with:
POST /api/verify-dni: receives the file with@fastify/multipart, analyses it with Constaia and returns the verdict.POST /webhooks/constaia: verifies the signature on the raw body using its own content-type parser, scoped to that route.
The API key lives only on the server. The browser or the widget send the file to your route.
Install
npm i fastify @fastify/multipart @constaia/sdk
npm i -D typescript tsxRequirements: Node.js 20 or later, Fastify 5 and @fastify/multipart 9.
Environment variables
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).
Shared client and helpers
import {
AuthenticationError,
Constaia,
ConstaiaError,
InsufficientCreditsError,
InvalidRequestError,
PermissionError,
RateLimitError,
type Analysis,
type WebhookEvent,
} from "@constaia/sdk";
// Reads CONSTAIA_API_KEY from the environment.
export const constaia = new Constaia();
// 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 };
}
export type HttpError = {
status: number;
body: { error: { code: string; message: string } };
retryAfter?: number;
};
const httpError = (status: number, code: string, message: string, retryAfter?: number): HttpError => ({
status,
body: { error: { code, message } },
retryAfter,
});
export function toHttpError(error: unknown): HttpError {
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
import Fastify from "fastify";
import multipart from "@fastify/multipart";
import { WebhookVerificationError } from "@constaia/sdk";
import {
alreadyProcessed,
constaia,
handleEvent,
markProcessed,
publicResult,
toHttpError,
} from "./constaia.js";
const app = Fastify({ logger: true });
await app.register(multipart, {
limits: { fileSize: 20 * 1024 * 1024, files: 1, fields: 5 },
});
// Put your authentication here (for example an onRequest hook): every call spends credits.
app.post("/api/verify-dni", async (request, reply) => {
let file: { buffer: Buffer; filename: string } | undefined;
let fullName = "";
for await (const part of request.parts()) {
if (part.type === "file" && part.fieldname === "file") {
// toBuffer() throws a 413 error if it exceeds limits.fileSize.
file = { buffer: await part.toBuffer(), filename: part.filename };
} else if (part.type === "file") {
part.file.resume();
} else if (part.fieldname === "full_name") {
fullName = String(part.value).trim();
}
}
if (!file) {
return reply.code(400).send({ error: { code: "missing_file", message: "No file received." } });
}
try {
const analysis = await constaia.analyze(file.buffer, {
filename: file.filename,
expect: "es_dni",
checks: { notExpired: true, ...(fullName ? { holder: { fullName } } : {}) },
storage: "none",
language: "en",
});
// Store analysis.id and analysis.verdict in your database.
return reply.code(analysis.status === "completed" ? 200 : 202).send(publicResult(analysis));
} catch (error) {
const { status, body, retryAfter } = toHttpError(error);
if (retryAfter) reply.header("Retry-After", retryAfter);
return reply.code(status).send(body);
}
});
// Encapsulated context: the raw-body parser only affects this route.
await app.register(async (scope) => {
scope.addContentTypeParser("application/json", { parseAs: "string" }, (_request, body, done) => {
done(null, body);
});
scope.post("/webhooks/constaia", async (request, reply) => {
let event;
try {
event = await constaia.webhooks.verify(
request.body as string,
request.headers,
process.env.CONSTAIA_WEBHOOK_SECRET!,
);
} catch (error) {
if (error instanceof WebhookVerificationError) return reply.code(400).send("Invalid signature");
throw error;
}
const webhookId = request.headers["webhook-id"] as string;
if (!alreadyProcessed(webhookId)) {
await handleEvent(event);
markProcessed(webhookId);
}
return reply.code(200).send();
});
});
await app.listen({ port: Number(process.env.PORT ?? 3000), host: "0.0.0.0" });Start it with:
npx tsx --env-file=.env src/server.tsKey points:
request.parts()walks files and fields in arrival order, so it doesn't matter whetherfull_namecomes before or after the file. Unexpected files are drained withpart.file.resume().- The server decides
expectandchecks, never the client. Take the holder's name from the session rather than from the form. filenameis required in practice when you pass aBuffer: in test mode the name decides the response.- Encapsulated parser.
addContentTypeParserinsideapp.registerdoes not change the JSON parser for the rest of the app. - 202 when the analysis is still
queuedorprocessingafter 30 s: the result arrives by webhook.
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": []
}It is the shape the widget renders: <constaia-upload endpoint="/api/verify-dni">.
Errors
| SDK error | When | What your route returns |
|---|---|---|
InvalidRequestError | Empty, 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 |
RateLimitError | You exceed your key's requests per second (429). The SDK already retries twice honouring Retry-After | 429 with Retry-After |
InsufficientCreditsError | No credits left (402) | 503 to the user and an alert for you: top up in the dashboard |
AuthenticationError, PermissionError | Missing, revoked or wrong key (401/403) | 500: it is your configuration problem, not the user's |
APIError, APIConnectionError, APITimeoutError | Constaia 5xx or network error, after retries are exhausted | 502 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.
If the file exceeds limits.fileSize, @fastify/multipart throws RequestFileTooLargeError and Fastify answers 413 without calling Constaia.
Webhook
- The signature is computed over the exact body. With
parseAs: "string"you get the text as sent; don't useJSON.stringify(request.body). webhook-idis stable across retries: store it with a unique constraint.- Answer 2xx within 15 s; queue heavy work.
- If a global auth hook protects your routes, exclude
/webhooks/constaia.
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| File | full_name | Result |
|---|---|---|
dni_valid.jpg | María García López | valid |
dni_valid.jpg | Juan Pérez | invalid: 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, Fastify has inject, which opens no ports:
import { signWebhook } from "@constaia/sdk";
const payload = 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(payload, process.env.CONSTAIA_WEBHOOK_SECRET!);
const res = await app.inject({
method: "POST",
url: "/webhooks/constaia",
headers: { ...headers, "content-type": "application/json" },
payload,
});
console.log(res.statusCode); // 200(To use inject, export app from a module that does not call listen.)
Production checklist
- Authentication and rate limiting (for example
@fastify/rate-limit) on/api/verify-dni. - 20 MB upload limit in
@fastify/multipartand slightly more (21 MB) in the proxy. - Timeouts of 60 s or more in proxy and load balancer; a synchronous analysis can take up to 30 s.
- PDFs over 30 pages or high volume:
async: true+ webhook, or batches. -
ck_live_key only in the server environment. - Alert on
InsufficientCreditsErrorand thecredits.lowevent.