Google Cloud Functions
Validate documents with Constaia on Google Cloud Functions (Cloud Run functions): busboy multipart, Secret Manager keys and a rawBody webhook.
You will deploy two HTTP functions with @google-cloud/functions-framework (2nd gen Cloud Functions, now Cloud Run functions):
verifyDni: accepts the file asmultipart/form-data(parsed with busboy) or as base64 JSON, analyses it with Constaia and returns the verdict.constaiaWebhook: verifies the signature withreq.rawBodyand drops duplicates.
The key lives in Secret Manager and reaches the function as an environment variable.
Install
npm i @google-cloud/functions-framework @constaia/sdk busboy
npm i -D typescript @types/busboy @types/nodeIn package.json, point main at the compiled code and add gcp-build so deployment compiles TypeScript:
{
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"gcp-build": "tsc",
"start": "functions-framework --target=verifyDni"
}
}Secrets
printf 'ck_test_...' | gcloud secrets create constaia-api-key --data-file=-
printf 'whsec_...' | gcloud secrets create constaia-webhook-secret --data-file=-The functions' service account needs the roles/secretmanager.secretAccessor role. Locally, use a .env with CONSTAIA_API_KEY and CONSTAIA_WEBHOOK_SECRET (for example with node --env-file=.env).
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;
}
}Functions
Cloud Functions does not parse multipart/form-data, but it keeps the full body in req.rawBody. busboy walks it and extracts the file and the fields.
import * as functions from "@google-cloud/functions-framework";
import busboy from "busboy";
import { WebhookVerificationError, type AnalyzeInput } from "@constaia/sdk";
import {
alreadyProcessed,
constaia,
handleEvent,
markProcessed,
publicResult,
toHttpError,
} from "./constaia.js";
type Upload = { buffer: Buffer; filename: string; truncated: boolean; fields: Record<string, string> };
function parseMultipart(req: functions.Request): Promise<Upload | null> {
return new Promise((resolve, reject) => {
const bb = busboy({ headers: req.headers, limits: { fileSize: 20 * 1024 * 1024, files: 1 } });
const fields: Record<string, string> = {};
let upload: Upload | null = null;
bb.on("field", (name, value) => {
fields[name] = value;
});
bb.on("file", (name, stream, info) => {
if (name !== "file") return void stream.resume();
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
stream.on("end", () => {
upload = { buffer: Buffer.concat(chunks), filename: info.filename, truncated: Boolean(stream.truncated), fields };
});
});
bb.on("close", () => resolve(upload));
bb.on("error", reject);
bb.end(req.rawBody);
});
}
// Protect this function (IAM or a token from your login system): every call spends credits.
functions.http("verifyDni", async (req, res) => {
if (req.method !== "POST") {
res.status(405).end();
return;
}
let input: AnalyzeInput;
let fullName = "";
if (req.is("multipart/form-data")) {
const upload = await parseMultipart(req);
if (!upload) {
res.status(400).json({ error: { code: "missing_file", message: "No file received." } });
return;
}
if (upload.truncated) {
res.status(413).json({ error: { code: "file_too_large", message: "20 MB maximum." } });
return;
}
input = { file: upload.buffer, filename: upload.filename };
fullName = (upload.fields.full_name ?? "").trim();
} else if (req.is("application/json") && typeof req.body?.base64 === "string") {
input = { base64: req.body.base64, filename: String(req.body.filename ?? "document.jpg") };
fullName = String(req.body.full_name ?? "").trim();
} else {
res.status(400).json({ error: { code: "missing_file", message: "Send multipart or JSON with base64." } });
return;
}
try {
const analysis = await constaia.analyze(input, {
expect: "es_dni",
checks: { notExpired: true, ...(fullName ? { holder: { fullName } } : {}) },
storage: "none",
language: "en",
});
// Store analysis.id and analysis.verdict in your database.
res.status(analysis.status === "completed" ? 200 : 202).json(publicResult(analysis));
} catch (error) {
const { status, body, retryAfter } = toHttpError(error);
if (retryAfter) res.set("Retry-After", String(retryAfter));
res.status(status).json(body);
}
});
functions.http("constaiaWebhook", async (req, res) => {
let event;
try {
// req.rawBody holds the exact bytes; req.body is already parsed and useless for the signature.
event = await constaia.webhooks.verify(req.rawBody ?? "", req.headers, process.env.CONSTAIA_WEBHOOK_SECRET!);
} catch (error) {
if (!(error instanceof WebhookVerificationError)) throw error;
res.status(400).send("Invalid signature");
return;
}
const webhookId = req.get("webhook-id")!;
if (!alreadyProcessed(webhookId)) {
await handleEvent(event);
markProcessed(webhookId);
}
res.status(204).end();
});- The server sets
expectandchecks; take the holder's name from the authenticated user rather than the form. - In-memory dedupe does not survive across instances. In production use Firestore (
doc(webhookId).create()fails if it already exists) or your database. - Base64 JSON is a third larger than the file. For large files it is better to upload them to Cloud Storage and pass a signed URL as
fileUrl(see Firebase Functions).
Deploy
npm run build
gcloud functions deploy verify-dni --gen2 --runtime=nodejs22 --region=europe-west1 \
--source=. --entry-point=verifyDni --trigger-http --timeout=120s \
--set-secrets=CONSTAIA_API_KEY=constaia-api-key:latest
gcloud functions deploy constaia-webhook --gen2 --runtime=nodejs22 --region=europe-west1 \
--source=. --entry-point=constaiaWebhook --trigger-http --allow-unauthenticated \
--set-secrets=CONSTAIA_API_KEY=constaia-api-key:latest,CONSTAIA_WEBHOOK_SECRET=constaia-webhook-secret:latest--timeout=120s: a synchronous analysis can take up to 30 s.- The webhook must be public (
--allow-unauthenticated): Constaia authenticates with the signature, not IAM. - Check the Google Cloud documentation for your function's maximum HTTP request size.
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 build && npx functions-framework --target=verifyDni --port=8080
curl -F "file=@dni_valid.jpg" -F "full_name=María García López" http://localhost:8080| 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.
With base64 JSON:
curl -H "content-type: application/json" http://localhost:8080 \
-d "{\"filename\":\"dni_valid.jpg\",\"base64\":\"$(base64 < dni_valid.jpg | tr -d '\n')\"}"Production checklist
-
verify-dniprotected (IAM or user token) and rate limited; the webhook public but verified. - 20 MB upload limit in busboy.
-
--timeoutof 60 s or more. - PDFs over 30 pages or high volume:
async: true+ webhook, or batches. - Persistent webhook dedupe (Firestore or a database).
-
ck_live_key only in Secret Manager. - Cloud Logging alert on
InsufficientCreditsErrorand thecredits.lowevent.
Next steps
AWS Lambda
Validate documents with Constaia on AWS Lambda: direct S3 uploads, a presigned URL as fileUrl, keys in Secrets Manager and a signed webhook.
Firebase Functions
Validate documents with Constaia on Cloud Functions for Firebase v2: a Storage trigger with a signed URL, base64 onRequest, defineSecret and webhook.