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 asfileUrland 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 withreq.rawBodyand dedupes in Firestore.
The key is stored with defineSecret: it never goes into the app or the code.
Install
cd functions
npm i @constaia/sdkfirebase-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_SECRETThe 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.
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
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.
getSignedUrlneeds the function's service account to haveiam.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.rawBodyholds the exact bytes;req.bodyis already parsed and useless for the signature.- Region.
europe-west1keeps your side of the processing in the EU.
In the app
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,storageRegister 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 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.
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}:
| File | verdict.status |
|---|---|
dni_valid.jpg | valid (if the profile fullName is María García López or empty) |
dni_expired.jpg | invalid: not_expired with severity error |
blurry.jpg | review: 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.
-
timeoutSecondsof 60 or more on functions that call Constaia. - PDFs over 30 pages:
async: trueand the result through the webhook. -
ck_live_key only in Secret Manager (defineSecret). - Cloud Logging alert on credit errors and the
credits.lowevent.
Next steps
Google Cloud Functions
Validate documents with Constaia on Google Cloud Functions (Cloud Run functions): busboy multipart, Secret Manager keys and a rawBody webhook.
Supabase Edge Functions
Validate documents with Constaia in Supabase Edge Functions: Storage uploads, a signed URL as fileUrl, keys in supabase secrets and a webhook.