NestJS
Integrate Constaia with NestJS 11: a module that provides the client, FileInterceptor for uploads and a webhook verified with rawBody.
You will add to a NestJS 11 application (Express platform):
- A global
ConstaiaModulethat provides the SDK client fromConfigService. - A service and a controller with
FileInterceptorforPOST /api/verify-dni. - An exception filter that maps SDK errors to HTTP responses.
- A webhook controller that verifies the signature with
rawBody: trueand drops duplicates.
The key lives only on the server. The browser or the widget send the file to your API.
Install
npm i @constaia/sdk @nestjs/config
npm i -D @types/multer@nestjs/platform-express (which includes multer) already ships with a project created by nest new.
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).
Client module
import { Global, Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Constaia } from "@constaia/sdk";
@Global()
@Module({
providers: [
{
provide: Constaia,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
new Constaia({ apiKey: config.getOrThrow<string>("CONSTAIA_API_KEY") }),
},
],
exports: [Constaia],
})
export class ConstaiaModule {}The Constaia class itself is the injection token: any service can ask for it in its constructor.
Error filter
import { type ArgumentsHost, Catch, type ExceptionFilter, Logger } from "@nestjs/common";
import type { Response } from "express";
import {
AuthenticationError,
ConstaiaError,
InsufficientCreditsError,
InvalidRequestError,
PermissionError,
RateLimitError,
} from "@constaia/sdk";
@Catch(ConstaiaError)
export class ConstaiaExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger("Constaia");
catch(error: ConstaiaError, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse<Response>();
const send = (status: number, code: string, message: string) =>
res.status(status).json({ error: { code, message } });
if (error instanceof InvalidRequestError) {
return send(error.status === 413 ? 413 : 422, error.code ?? "invalid_request", error.message);
}
if (error instanceof RateLimitError) {
res.setHeader("Retry-After", String(Math.ceil(error.retryAfter ?? 1)));
return send(429, "rate_limited", "Too many requests. Try again in a few seconds.");
}
if (error instanceof InsufficientCreditsError) {
this.logger.error(`Out of credits. Top up at https://app.constaia.com (${error.requestId})`);
return send(503, "unavailable", "Document validation is temporarily unavailable.");
}
if (error instanceof AuthenticationError || error instanceof PermissionError) {
this.logger.error(`Check CONSTAIA_API_KEY: ${error.code} (${error.requestId})`);
return send(500, "misconfigured", "Server configuration error.");
}
// APIError, APIConnectionError, APITimeoutError
this.logger.error(`${error.name} ${error.code} (${error.requestId})`);
return send(502, "upstream_error", "The document could not be analysed. Please try again.");
}
}Service and controller
import { Injectable } from "@nestjs/common";
import { Constaia, type Analysis } from "@constaia/sdk";
// What the browser sees (and 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 };
}
@Injectable()
export class DocumentsService {
constructor(private readonly constaia: Constaia) {}
async verifyDni(file: Express.Multer.File, holderName?: string): Promise<Analysis> {
const analysis = await this.constaia.analyze(file.buffer, {
filename: file.originalname,
expect: "es_dni",
checks: { notExpired: true, ...(holderName ? { holder: { fullName: holderName } } : {}) },
storage: "none",
language: "en",
});
// Store analysis.id and analysis.verdict in your database.
return analysis;
}
}import {
BadRequestException,
Body,
Controller,
HttpCode,
Post,
UploadedFile,
UseInterceptors,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { DocumentsService, publicResult } from "./documents.service";
@Controller("api")
export class DocumentsController {
constructor(private readonly documents: DocumentsService) {}
// Add your auth guard and a throttler here: every call spends credits.
@Post("verify-dni")
@HttpCode(200)
@UseInterceptors(FileInterceptor("file", { limits: { fileSize: 20 * 1024 * 1024, files: 1 } }))
async verifyDni(
@UploadedFile() file: Express.Multer.File | undefined,
@Body("full_name") fullName?: string,
) {
if (!file) {
throw new BadRequestException({ error: { code: "missing_file", message: "No file received." } });
}
// Even better: the authenticated user's name, not the form's.
const analysis = await this.documents.verifyDni(file, fullName?.trim());
return publicResult(analysis);
}
}FileInterceptoruses in-memory multer:file.bufferandfile.originalnamego straight to the SDK. If the file exceedsfileSize, Nest answers 413.- The server sets
expectandchecks; never take them from the client. - If the analysis takes longer than 30 s,
statuscomes back asqueuedorprocessingand you get the final result by webhook.
Webhook
import {
BadRequestException,
Controller,
Headers,
HttpCode,
Logger,
Post,
type RawBodyRequest,
Req,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Request } from "express";
import { Constaia, WebhookVerificationError, type WebhookEvent } from "@constaia/sdk";
@Controller("webhooks")
export class WebhooksController {
private readonly logger = new Logger(WebhooksController.name);
// In production, a table with a unique constraint on webhook-id.
private readonly processed = new Set<string>();
constructor(
private readonly constaia: Constaia,
private readonly config: ConfigService,
) {}
@Post("constaia")
@HttpCode(200)
async handle(@Req() req: RawBodyRequest<Request>, @Headers("webhook-id") webhookId: string) {
let event: WebhookEvent;
try {
event = await this.constaia.webhooks.verify(
req.rawBody ?? "",
req.headers,
this.config.getOrThrow<string>("CONSTAIA_WEBHOOK_SECRET"),
);
} catch (error) {
if (error instanceof WebhookVerificationError) throw new BadRequestException("Invalid signature");
throw error;
}
if (this.processed.has(webhookId)) return;
switch (event.type) {
case "analysis.completed":
this.logger.log(`analysis.completed ${event.data.id} ${event.data.verdict?.status}`);
break;
case "analysis.review_required":
this.logger.log(`Needs manual review: ${event.data.id}`);
break;
case "analysis.failed":
this.logger.warn(`analysis.failed ${event.data.id} ${event.data.error?.code}`);
break;
case "credits.low":
this.logger.warn("Credits running low: top up at https://app.constaia.com");
break;
}
this.processed.add(webhookId);
}
}req.rawBody is the exact Buffer received. req.body exists too (Nest still parses it), but re-serialising it changes the bytes and breaks the signature.
Root module and bootstrap
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { ConstaiaModule } from "./constaia/constaia.module";
import { DocumentsController } from "./documents/documents.controller";
import { DocumentsService } from "./documents/documents.service";
import { WebhooksController } from "./webhooks/webhooks.controller";
@Module({
imports: [ConfigModule.forRoot({ isGlobal: true }), ConstaiaModule],
controllers: [DocumentsController, WebhooksController],
providers: [DocumentsService],
})
export class AppModule {}import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import { AppModule } from "./app.module";
import { ConstaiaExceptionFilter } from "./constaia/constaia-exception.filter";
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true });
app.useBodyParser("json", { limit: "1mb" });
app.useGlobalFilters(new ConstaiaExceptionFilter());
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();rawBody: truekeeps the unparsed body inreq.rawBodyfor every route handled by the JSON parser.useBodyParser("json", { limit: "1mb" })raises the default limit (100 KB) in case an event carries a large analysis.
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 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.
Test in test mode
npm run start:dev
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, sign a body with signWebhook and post it to the route:
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); // 200Production checklist
- Auth guard and
@nestjs/throttleronDocumentsController; keep the webhook outside the guard (it authenticates with the signature). - 20 MB upload limit in
FileInterceptorand 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. - Persistent webhook dedupe (unique constraint on
webhook-id). -
ck_live_key only in the server environment. - Alert on
InsufficientCreditsErrorand thecredits.lowevent.