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.
On Lambda you don't want the file to go through your function: API Gateway and Function URLs deliver binary bodies base64-encoded inside a JSON event, and synchronous invocations have a payload size limit (6 MB according to the AWS documentation). The recommended pattern:
- The browser asks
upload-urlfor a presigned S3 URL and uploads the file directly to S3. - The browser calls
verifywith the object key. The function creates a presigned read URL and passes it to Constaia asfileUrl. The file never goes through Lambda. webhookreceives Constaia events, verifies the signature on the raw body and dedupes in DynamoDB.
The Constaia key sits in Secrets Manager and only the function reads it.
Install
npm i @constaia/sdk @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @aws-sdk/client-secrets-manager @aws-sdk/client-dynamodb aws-jwt-verify
npm i -D @types/aws-lambdaRuntime nodejs20.x or later, ES modules. Bundle with your usual tool (SAM, CDK, Serverless Framework, esbuild).
Secrets and configuration
Store a JSON secret in Secrets Manager:
aws secretsmanager create-secret --name constaia/prod \
--secret-string '{"CONSTAIA_API_KEY":"ck_test_...","CONSTAIA_WEBHOOK_SECRET":"whsec_..."}'Function environment variables (not secret):
| Variable | Value |
|---|---|
CONSTAIA_SECRET_ID | constaia/prod |
UPLOAD_BUCKET | Private upload bucket, in an EU region |
USER_POOL_ID, USER_POOL_CLIENT_ID | Your Cognito user pool (to authenticate the user) |
WEBHOOK_TABLE | DynamoDB table with partition key id (string) and TTL on expires_at |
If you prefer Parameter Store (SSM), swap getSecrets for a GetParameterCommand with WithDecryption: true; nothing else changes.
Shared helpers
import { GetSecretValueCommand, SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
import {
AuthenticationError,
Constaia,
ConstaiaError,
InsufficientCreditsError,
InvalidRequestError,
PermissionError,
RateLimitError,
type Analysis,
type WebhookEvent,
} from "@constaia/sdk";
type Secrets = { CONSTAIA_API_KEY: string; CONSTAIA_WEBHOOK_SECRET: string };
// Loaded once per container from Secrets Manager, not on every invocation.
const secretsManager = new SecretsManagerClient({});
let secrets: Promise<Secrets> | undefined;
export function getSecrets(): Promise<Secrets> {
secrets ??= secretsManager
.send(new GetSecretValueCommand({ SecretId: process.env.CONSTAIA_SECRET_ID }))
.then((res) => JSON.parse(res.SecretString!) as Secrets);
return secrets;
}
let client: Constaia | undefined;
export async function getConstaia(): Promise<Constaia> {
client ??= new Constaia({ apiKey: (await getSecrets()).CONSTAIA_API_KEY });
return client;
}
// 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.");
}
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;
}
}import { CognitoJwtVerifier } from "aws-jwt-verify";
import type { APIGatewayProxyEventV2 } from "aws-lambda";
const verifier = CognitoJwtVerifier.create({
userPoolId: process.env.USER_POOL_ID!,
clientId: process.env.USER_POOL_CLIENT_ID!,
tokenUse: "id",
});
export async function authenticate(event: APIGatewayProxyEventV2) {
const token = event.headers.authorization?.replace(/^Bearer /i, "");
if (!token) return null;
try {
return await verifier.verify(token);
} catch {
return null;
}
}
export function readBody(event: APIGatewayProxyEventV2): string {
if (!event.body) return "";
return event.isBase64Encoded ? Buffer.from(event.body, "base64").toString("utf8") : event.body;
}
export const json = (statusCode: number, body: unknown, headers: Record<string, string> = {}) => ({
statusCode,
headers: { "content-type": "application/json", ...headers },
body: JSON.stringify(body),
});1. Upload URL
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda";
import { authenticate, json, readBody } from "./auth.js";
const s3 = new S3Client({});
const ALLOWED = new Set(["image/jpeg", "image/png", "image/webp", "image/heic", "application/pdf"]);
export async function handler(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
const user = await authenticate(event);
if (!user) return json(401, { error: { code: "unauthorized", message: "Sign in first." } });
const { filename, contentType } = JSON.parse(readBody(event) || "{}");
if (!ALLOWED.has(contentType)) {
return json(415, { error: { code: "unsupported_file_type", message: "Upload an image or a PDF." } });
}
// The file name stays at the end of the key: Constaia takes it from the URL.
const safeName = String(filename ?? "document").replace(/[^\w.-]/g, "_").slice(-100);
const key = `uploads/${user.sub}/${crypto.randomUUID()}/${safeName}`;
const url = await getSignedUrl(
s3,
new PutObjectCommand({ Bucket: process.env.UPLOAD_BUCKET, Key: key, ContentType: contentType }),
{ expiresIn: 300 },
);
return json(200, { key, url });
}2. Verification
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda";
import { authenticate, json, readBody } from "./auth.js";
import { getConstaia, publicResult, toHttpError } from "./constaia.js";
const s3 = new S3Client({});
export async function handler(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
const user = await authenticate(event);
if (!user) return json(401, { error: { code: "unauthorized", message: "Sign in first." } });
const { key } = JSON.parse(readBody(event) || "{}");
if (typeof key !== "string" || !key.startsWith(`uploads/${user.sub}/`)) {
return json(403, { error: { code: "forbidden", message: "Invalid file." } });
}
// Short-lived read URL: Constaia downloads the file (https, max 20 MB, 15 s).
const fileUrl = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: process.env.UPLOAD_BUCKET, Key: key }),
{ expiresIn: 300 },
);
const fullName = typeof user.name === "string" ? user.name : "";
try {
const constaia = await getConstaia();
const analysis = await constaia.analyze(
{ fileUrl },
{
expect: "es_dni",
checks: { notExpired: true, ...(fullName ? { holder: { fullName } } : {}) },
storage: "none",
language: "en",
metadata: { user_id: user.sub },
},
);
// Store analysis.id and analysis.verdict in your database.
return json(analysis.status === "completed" ? 200 : 202, publicResult(analysis));
} catch (error) {
const { status, body, retryAfter } = toHttpError(error);
return json(status, body, retryAfter ? { "retry-after": String(retryAfter) } : {});
}
}- The holder's name comes from the token (
nameclaim), not from a form, and the function decidesexpect/checks. - Add a lifecycle rule to the bucket that deletes
uploads/after a day: Constaia does not need the file after the analysis. - If the analysis doesn't finish within 30 s, the response is 202 with
status: "queued"or"processing"and the result arrives by webhook.
In the browser
export async function verifyDni(file: File, idToken: string) {
const auth = { authorization: `Bearer ${idToken}`, "content-type": "application/json" };
const upload = await fetch(UPLOAD_URL_FUNCTION, {
method: "POST",
headers: auth,
body: JSON.stringify({ filename: file.name, contentType: file.type }),
}).then((r) => r.json());
await fetch(upload.url, { method: "PUT", headers: { "content-type": file.type }, body: file });
const res = await fetch(VERIFY_FUNCTION, { method: "POST", headers: auth, body: JSON.stringify({ key: upload.key }) });
return res.json(); // { id, status, verdict, warnings, … }
}The bucket needs a CORS rule that allows PUT from your domain.
Alternative: base64 in JSON
For small images you can send the file base64-encoded inside the JSON and pass it straight to the SDK:
const { filename, base64 } = JSON.parse(readBody(event));
const analysis = await constaia.analyze({ base64, filename }, { expect: "es_dni", checks: { notExpired: true } });Base64 is a third larger than the file and counts towards the Lambda payload limit, so it doesn't work for PDFs or large photos.
3. Webhook
import {
ConditionalCheckFailedException,
DeleteItemCommand,
DynamoDBClient,
PutItemCommand,
} from "@aws-sdk/client-dynamodb";
import { verifyWebhook, WebhookVerificationError, type WebhookEvent } from "@constaia/sdk";
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda";
import { readBody } from "./auth.js";
import { getSecrets, handleEvent } from "./constaia.js";
const ddb = new DynamoDBClient({});
const TableName = process.env.WEBHOOK_TABLE;
export async function handler(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
// Raw body: decoded if base64, never re-serialised.
const rawBody = readBody(event);
const { CONSTAIA_WEBHOOK_SECRET } = await getSecrets();
let webhookEvent: WebhookEvent;
try {
webhookEvent = await verifyWebhook(rawBody, event.headers, CONSTAIA_WEBHOOK_SECRET);
} catch (error) {
if (error instanceof WebhookVerificationError) return { statusCode: 400, body: "Invalid signature" };
throw error;
}
const id = { S: event.headers["webhook-id"]! };
try {
await ddb.send(
new PutItemCommand({
TableName,
Item: { id, expires_at: { N: String(Math.floor(Date.now() / 1000) + 7 * 24 * 3600) } },
ConditionExpression: "attribute_not_exists(id)",
}),
);
} catch (error) {
if (error instanceof ConditionalCheckFailedException) return { statusCode: 200, body: "" };
throw error;
}
try {
await handleEvent(webhookEvent);
} catch (error) {
// Release the id so Constaia's retry processes it again.
await ddb.send(new DeleteItemCommand({ TableName, Key: { id } }));
throw error;
}
return { statusCode: 200, body: "" };
}verifyWebhook is the SDK's standalone function: the webhook doesn't need the API key, only the whsec_… secret.
Timeouts and deployment
- Lambda timeout: 60 s or more on
verify(a synchronous analysis can take up to 30 s, plus the download). - API Gateway cuts the integration at around 30 s (check the exact limit in the AWS documentation). For
verify, use a Function URL, which honours the function timeout, or call withasync: trueand deliver the result by webhook. - IAM permissions:
secretsmanager:GetSecretValueon the secret,s3:PutObject/s3:GetObjectonuploads/*,dynamodb:PutItem/dynamodb:DeleteItemon the table. - The webhook URL must be https and answer 2xx within 15 s. Register it in the dashboard or with
constaia.webhookEndpoints.create.
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
With a ck_test_ key in the secret, upload a file named dni_valid.jpg. The S3 key ends with that name and so does the presigned URL, which is what Constaia uses in test mode to pick the response.
UPLOAD=$(curl -s -X POST "$UPLOAD_URL_FUNCTION" -H "authorization: Bearer $ID_TOKEN" \
-d '{"filename":"dni_valid.jpg","contentType":"image/jpeg"}')
curl -X PUT -H "content-type: image/jpeg" --data-binary @dni_valid.jpg "$(echo "$UPLOAD" | jq -r .url)"
curl -X POST "$VERIFY_FUNCTION" -H "authorization: Bearer $ID_TOKEN" \
-d "{\"key\":\"$(echo "$UPLOAD" | jq -r .key)\"}"| File | Result |
|---|---|
dni_valid.jpg | valid if the name claim is María García López (or missing); invalid on holder for any other name |
dni_expired.jpg | invalid: not_expired with severity error |
blurry.jpg | review: reason low_quality |
More names in Test mode.
Production checklist
- Authentication on
upload-urlandverify, and per-user rate limiting (every verification spends credits). - Private EU bucket, CORS limited to your domain and a lifecycle rule on
uploads/. - Timeout of 60 s or more on
verify; Function URL orasync: trueif you use API Gateway. - PDFs over 30 pages:
async: true+ webhook, or batches. -
ck_live_key only in Secrets Manager. - CloudWatch alarm on
InsufficientCreditsErrorlog lines and thecredits.lowevent.
Next steps
Edge Functions
Use Constaia in Vercel Edge Functions and Netlify Edge Functions: document uploads, a WebCrypto-verified webhook and runtime limits to check.
Google Cloud Functions
Validate documents with Constaia on Google Cloud Functions (Cloud Run functions): busboy multipart, Secret Manager keys and a rawBody webhook.