Constaia
Integrations

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.

Esta página ainda não está traduzida para o seu idioma. Mostramos a versão em inglês.

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:

  1. The browser asks upload-url for a presigned S3 URL and uploads the file directly to S3.
  2. The browser calls verify with the object key. The function creates a presigned read URL and passes it to Constaia as fileUrl. The file never goes through Lambda.
  3. webhook receives 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-lambda

Runtime 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):

VariableValue
CONSTAIA_SECRET_IDconstaia/prod
UPLOAD_BUCKETPrivate upload bucket, in an EU region
USER_POOL_ID, USER_POOL_CLIENT_IDYour Cognito user pool (to authenticate the user)
WEBHOOK_TABLEDynamoDB 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

src/constaia.ts
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;
  }
}
src/auth.ts
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

src/upload-url.ts
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

src/verify.ts
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 (name claim), not from a form, and the function decides expect/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

web/verify.ts
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:

src/verify-base64.ts (excerpt)
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

src/webhook.ts
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 with async: true and deliver the result by webhook.
  • IAM permissions: secretsmanager:GetSecretValue on the secret, s3:PutObject/s3:GetObject on uploads/*, dynamodb:PutItem/dynamodb:DeleteItem on 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 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

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)\"}"
FileResult
dni_valid.jpgvalid if the name claim is María García López (or missing); invalid on holder for any other name
dni_expired.jpginvalid: not_expired with severity error
blurry.jpgreview: reason low_quality

More names in Test mode.

Production checklist

  • Authentication on upload-url and verify, 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 or async: true if you use API Gateway.
  • PDFs over 30 pages: async: true + webhook, or batches.
  • ck_live_ key only in Secrets Manager.
  • CloudWatch alarm on InsufficientCreditsError log lines and the credits.low event.

Next steps

Nesta página