Constaia
Integrations

Firebase Functions

Validate documents with Constaia on Cloud Functions for Firebase v2: a Storage trigger with a signed URL, base64 onRequest, defineSecret and webhook.

You will build with Cloud Functions for Firebase (2nd gen):

  • analyzeUpload (onObjectFinalized): the user uploads their document to Cloud Storage with the Firebase SDK; the function creates a signed URL, passes it to Constaia as fileUrl and writes the verdict to Firestore. The app receives it in real time.
  • verifyDni (onRequest): a synchronous alternative for small base64 files, authenticated with the Firebase Auth ID token.
  • constaiaWebhook (onRequest): verifies the signature with req.rawBody and dedupes in Firestore.

The key is stored with defineSecret: it never goes into the app or the code.

Install

cd functions
npm i @constaia/sdk

firebase-functions and firebase-admin already come with a project created by firebase init functions.

Secrets

firebase functions:secrets:set CONSTAIA_API_KEY
firebase functions:secrets:set CONSTAIA_WEBHOOK_SECRET

The CLI prompts for the value (ck_test_..., whsec_...). Each function declares the ones it uses in secrets and reads them with .value() at run time. For the emulator, create functions/.secret.local with the same variables.

Storage rules

Each user can only upload to their own folder, files up to 20 MB, images or PDFs. Nobody can read them from the client.

storage.rules
rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    match /kyc/{uid}/{fileName} {
      allow create: if request.auth != null && request.auth.uid == uid
        && request.resource.size < 20 * 1024 * 1024
        && request.resource.contentType.matches('image/.*|application/pdf');
      allow read, update, delete: if false;
    }
  }
}

Functions

functions/src/index.ts
import { initializeApp } from "firebase-admin/app";
import { getAuth } from "firebase-admin/auth";
import { FieldValue, getFirestore } from "firebase-admin/firestore";
import { getStorage } from "firebase-admin/storage";
import { logger } from "firebase-functions";
import { defineSecret } from "firebase-functions/params";
import { onRequest } from "firebase-functions/v2/https";
import { onObjectFinalized } from "firebase-functions/v2/storage";
import type { Response } from "express";
import {
  AuthenticationError,
  Constaia,
  ConstaiaError,
  InsufficientCreditsError,
  InvalidRequestError,
  PermissionError,
  RateLimitError,
  verifyWebhook,
  WebhookVerificationError,
  type Analysis,
  type WebhookEvent,
} from "@constaia/sdk";

initializeApp();

const constaiaKey = defineSecret("CONSTAIA_API_KEY");
const webhookSecret = defineSecret("CONSTAIA_WEBHOOK_SECRET");
const REGION = "europe-west1";

// The secret only exists at run time: create the client on first use.
let client: Constaia | undefined;
const constaia = () => (client ??= new Constaia({ apiKey: constaiaKey.value() }));

function publicResult(analysis: Analysis) {
  const { id, object, status, document, verdict, warnings } = analysis;
  return { id, object, status, document, verdict, warnings };
}

function sendError(res: Response, error: unknown) {
  const send = (status: number, code: string, message: string) =>
    res.status(status).json({ error: { code, message } });
  if (error instanceof InvalidRequestError) return send(422, error.code ?? "invalid_request", error.message);
  if (error instanceof RateLimitError) {
    res.set("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) {
    logger.error("Constaia out of credits", { requestId: error.requestId });
    return send(503, "unavailable", "Document validation is temporarily unavailable.");
  }
  if (error instanceof AuthenticationError || error instanceof PermissionError) {
    logger.error("Check CONSTAIA_API_KEY", { code: error.code, requestId: error.requestId });
    return send(500, "misconfigured", "Server configuration error.");
  }
  if (error instanceof ConstaiaError) {
    logger.error("Constaia", { name: error.name, code: error.code, requestId: error.requestId });
    return send(502, "upstream_error", "The document could not be analysed. Please try again.");
  }
  throw error;
}

async function checksFor(uid: string) {
  // The expected holder comes from your user profile, not from the file or the client.
  const profile = (await getFirestore().doc(`users/${uid}`).get()).data();
  const fullName = typeof profile?.fullName === "string" ? profile.fullName : "";
  return { notExpired: true, ...(fullName ? { holder: { fullName } } : {}) };
}

// A) Upload to Storage → analysis → result in Firestore.
export const analyzeUpload = onObjectFinalized(
  { region: REGION, secrets: [constaiaKey], timeoutSeconds: 120 },
  async (event) => {
    const { bucket, name } = event.data;
    const match = name.match(/^kyc\/([^/]+)\/([^/]+)$/);
    if (!match) return;
    const [, uid, fileName] = match;

    const file = getStorage().bucket(bucket).file(name);
    const [fileUrl] = await file.getSignedUrl({ action: "read", expires: Date.now() + 10 * 60 * 1000 });
    const result = getFirestore().doc(`users/${uid}/documents/${fileName}`);

    try {
      const analysis = await constaia().analyze(
        { fileUrl },
        { expect: "es_dni", checks: await checksFor(uid), storage: "none", language: "en", metadata: { uid } },
      );
      await result.set(
        {
          analysisId: analysis.id,
          status: analysis.status,
          verdict: analysis.verdict ?? null,
          warnings: analysis.warnings,
          updatedAt: FieldValue.serverTimestamp(),
        },
        { merge: true },
      );
    } catch (error) {
      if (!(error instanceof InvalidRequestError)) throw error;
      await result.set({ status: "rejected", error: error.message, updatedAt: FieldValue.serverTimestamp() });
    }
    await file.delete({ ignoreNotFound: true });
  },
);

// B) Synchronous request with a base64 file (small files only).
export const verifyDni = onRequest(
  { region: REGION, secrets: [constaiaKey], timeoutSeconds: 120, cors: ["https://your-domain.com"] },
  async (req, res) => {
    if (req.method !== "POST") {
      res.status(405).end();
      return;
    }
    const token = req.get("authorization")?.replace(/^Bearer /i, "") ?? "";
    const user = await getAuth().verifyIdToken(token).catch(() => null);
    if (!user) {
      res.status(401).json({ error: { code: "unauthorized", message: "Sign in first." } });
      return;
    }
    const { base64, filename } = req.body ?? {};
    if (typeof base64 !== "string" || typeof filename !== "string") {
      res.status(400).json({ error: { code: "missing_file", message: "No file received." } });
      return;
    }

    try {
      const analysis = await constaia().analyze(
        { base64, filename },
        { expect: "es_dni", checks: await checksFor(user.uid), storage: "none", language: "en", metadata: { uid: user.uid } },
      );
      res.status(analysis.status === "completed" ? 200 : 202).json(publicResult(analysis));
    } catch (error) {
      sendError(res, error);
    }
  },
);

// C) Webhook: async results, reviews and credit alerts.
export const constaiaWebhook = onRequest({ region: REGION, secrets: [webhookSecret] }, async (req, res) => {
  let event: WebhookEvent;
  try {
    event = await verifyWebhook(req.rawBody, req.headers, webhookSecret.value());
  } catch (error) {
    if (!(error instanceof WebhookVerificationError)) throw error;
    res.status(400).send("Invalid signature");
    return;
  }

  const seen = getFirestore().doc(`constaiaWebhooks/${req.get("webhook-id")}`);
  try {
    await seen.create({ type: event.type, receivedAt: FieldValue.serverTimestamp() });
  } catch (error) {
    if ((error as { code?: number }).code === 6) {
      res.status(200).end(); // ALREADY_EXISTS: already processed
      return;
    }
    throw error;
  }

  try {
    if (event.type === "analysis.completed" || event.type === "analysis.review_required") {
      await getFirestore().doc(`analyses/${event.data.id}`).set({
        uid: event.data.metadata?.uid ?? null,
        verdict: event.data.verdict ?? null,
        updatedAt: FieldValue.serverTimestamp(),
      });
    } else if (event.type === "credits.low") {
      logger.warn("Constaia credits running low");
    }
  } catch (error) {
    await seen.delete(); // lets the retry process it
    throw error;
  }
  res.status(204).end();
});

Key points:

  • Signed URL as fileUrl. The file does not go through the function: Constaia downloads it (https, max 20 MB, 15 s) and the object name, at the end of the URL, is kept.
  • Permission to sign URLs. getSignedUrl needs the function's service account to have iam.serviceAccounts.signBlob (the Service Account Token Creator role on itself).
  • Deletion. The trigger deletes the file after the analysis and storage: "none" stops Constaia from keeping it. See Storage and privacy.
  • req.rawBody holds the exact bytes; req.body is already parsed and useless for the signature.
  • Region. europe-west1 keeps your side of the processing in the EU.

In the app

web/upload.ts
import { getAuth } from "firebase/auth";
import { doc, getFirestore, onSnapshot } from "firebase/firestore";
import { getStorage, ref, uploadBytes } from "firebase/storage";

export async function uploadDni(file: File, onResult: (data: unknown) => void) {
  const uid = getAuth().currentUser!.uid;
  await uploadBytes(ref(getStorage(), `kyc/${uid}/${file.name}`), file, { contentType: file.type });

  return onSnapshot(doc(getFirestore(), `users/${uid}/documents/${file.name}`), (snap) => {
    if (snap.exists()) onResult(snap.data()); // { status, verdict, warnings, … }
  });
}

The app never sees the Constaia key; it only uploads to its folder and reads its own document (add the matching Firestore rule).

Deploy

firebase deploy --only functions,storage

Register the constaiaWebhook URL in the dashboard or with constaia.webhookEndpoints.create and store the whsec_… with firebase functions:secrets:set CONSTAIA_WEBHOOK_SECRET.

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.

In the Storage trigger nobody is waiting for a response: file errors (InvalidRequestError) are stored in Firestore as rejected and the rest are rethrown so they show up in Cloud Logging. For automatic retries, set retry: true on the trigger.

Test in test mode

With CONSTAIA_API_KEY=ck_test_..., upload from the app a file named dni_valid.jpg, dni_expired.jpg or blurry.jpg and watch users/{uid}/documents/{fileName}:

Fileverdict.status
dni_valid.jpgvalid (if the profile fullName is María García López or empty)
dni_expired.jpginvalid: not_expired with severity error
blurry.jpgreview: reason low_quality

The Storage emulator produces local URLs that Constaia cannot download. Test the trigger in a real project with a test key, or use verifyDni in the emulator:

curl -X POST "http://127.0.0.1:5001/<project-id>/europe-west1/verifyDni" \
  -H "authorization: Bearer $ID_TOKEN" -H "content-type: application/json" \
  -d "{\"filename\":\"dni_valid.jpg\",\"base64\":\"$(base64 < dni_valid.jpg | tr -d '\n')\"}"

All file names are in Test mode.

Production checklist

  • Storage and Firestore rules that restrict each user to their folder and their document.
  • Per-user rate limiting (for example a Firestore counter) before analysing: every analysis spends credits.
  • timeoutSeconds of 60 or more on functions that call Constaia.
  • PDFs over 30 pages: async: true and the result through the webhook.
  • ck_live_ key only in Secret Manager (defineSecret).
  • Cloud Logging alert on credit errors and the credits.low event.

Next steps

On this page