Expo and React Native
Photograph documents in an Expo or React Native app with expo-image-picker, upload them to your backend with FormData and let the server call Constaia.
In this guide an Expo app takes a photo of the document, uploads it to your backend and shows the verdict. The backend (Node with Express) is the one that calls Constaia with the key.
Expo app ──photo (multipart)──▶ Your backend ──SDK──▶ api.constaia.com
◀────── verdict ────── ◀──────The key never goes in the app
Everything shipped in an app bundle can be extracted, including EXPO_PUBLIC_* variables. Do not put ck_live_... or ck_test_... in the app: the app only knows your backend URL and your user's session token.
The widget is web only
@constaia/widget is a web component that needs the browser DOM, so it does not work in React Native. In the app you capture with expo-image-picker (or expo-camera) and Constaia does the quality check: if the photo cannot be read reliably, the analysis comes back as review and you ask for another one.
Requirements
- Expo SDK 52 or later (or React Native with
expo-image-picker16+ installed). In earlier versions, writemediaTypesasImagePicker.MediaTypeOptions.Images. - Node ≥ 18 for the backend.
- A
ck_test_...test key from the dashboard.
1. Backend (Express)
npm i express multer @constaia/sdkCONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...import express from "express";
import multer from "multer";
import {
APITimeoutError,
AuthenticationError,
Constaia,
ConstaiaError,
InsufficientCreditsError,
InvalidRequestError,
PermissionError,
RateLimitError,
WebhookVerificationError,
} from "@constaia/sdk";
import { requireUser } from "./auth.mjs";
import { markEventProcessed, saveVerification, updateVerification } from "./verifications.mjs";
const constaia = new Constaia({ apiKey: process.env.CONSTAIA_API_KEY });
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 20 * 1024 * 1024 } });
const app = express();
app.post("/api/constaia", requireUser, upload.single("file"), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: { code: "file_required", message: "The photo is missing." } });
}
try {
const analysis = await constaia.analyze(req.file.buffer, {
filename: req.file.originalname,
expect: ["passport", "es_dni", "es_nie"],
checks: { notExpired: true, minAgeYears: 18 },
language: "en",
metadata: { user_id: String(res.locals.user.id) },
});
await saveVerification(res.locals.user.id, analysis);
res.status(analysis.status === "completed" ? 200 : 202).json(analysis);
} catch (err) {
sendError(res, err);
}
});
app.post("/api/webhooks/constaia", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = await constaia.webhooks.verify(req.body, req.headers, process.env.CONSTAIA_WEBHOOK_SECRET);
} catch (err) {
return res.status(err instanceof WebhookVerificationError ? 400 : 500).send("invalid webhook");
}
try {
if (await markEventProcessed(req.get("webhook-id"))) {
if (["analysis.completed", "analysis.review_required", "analysis.failed"].includes(event.type)) {
await updateVerification(event.data);
}
}
res.sendStatus(204);
} catch (err) {
console.error(err);
res.sendStatus(500);
}
});
app.use((err, _req, res, next) => {
if (err instanceof multer.MulterError && err.code === "LIMIT_FILE_SIZE") {
return res.status(413).json({ error: { code: "file_too_large", message: "The photo is larger than 20 MB." } });
}
next(err);
});
function sendError(res, err) {
if (err instanceof ConstaiaError) console.error("constaia", err.status, err.code, err.requestId, err.message);
else console.error(err);
const reply = (status, code, message) => res.status(status).json({ error: { code, message } });
if (err instanceof InvalidRequestError) return reply(err.status ?? 400, err.code ?? "invalid_request", err.message);
if (err instanceof RateLimitError) {
if (err.retryAfter) res.set("Retry-After", String(err.retryAfter));
return reply(429, "rate_limited", "Too many requests. Try again in a few seconds.");
}
if (err instanceof InsufficientCreditsError) return reply(503, "verification_unavailable", "Verification is not available right now.");
if (err instanceof AuthenticationError || err instanceof PermissionError) return reply(500, "server_misconfigured", "Server configuration error.");
if (err instanceof APITimeoutError) return reply(504, "timeout", "Verification took too long. Please try again.");
if (err instanceof ConstaiaError) return reply(502, "upstream_error", "The document could not be verified. Please try again.");
return reply(500, "internal_error", "Unexpected error.");
}
app.listen(3000, () => console.log("API on http://localhost:3000"));node --env-file=.env server.mjsrequireUser(yours) validates the session token the app sends inAuthorizationand puts the user inres.locals.user.saveVerification(),markEventProcessed()andupdateVerification()are your database.- The server decides
expectandchecks. The app sends no options: even if someone modifies the app or the request, they cannot change what is checked. - The webhook uses
express.raw()to get the raw body and verify the signature. It delivers the result of analyses that take longer than 30 s (the route then answers 202 withstatus: "queued"or"processing"); notify the app with a push notification or let it poll your backend. More in Webhooks and the Express guide.
| SDK error | HTTP to the app | Meaning |
|---|---|---|
InvalidRequestError | the same (400, 409, 413, 415, 422) | Invalid photo or request (empty, unsupported format, unreadable…). |
RateLimitError | 429 + Retry-After | You exceeded your key's requests per second. |
InsufficientCreditsError | 503 | No credits: alert your team. |
AuthenticationError, PermissionError | 500 | Missing, revoked or wrong key. |
APITimeoutError | 504 | The SDK hit its timeout. |
APIError, APIConnectionError | 502 | Constaia 5xx or network error. |
2. The app
Install and permissions
npx expo install expo-image-picker{
"expo": {
"plugins": [
[
"expo-image-picker",
{
"cameraPermission": "We need the camera to photograph your document.",
"photosPermission": "We need access to your photos so you can choose the document image."
}
]
]
}
}EXPO_PUBLIC_API_URL=https://api.your-domain.comThe backend URL can be public; the Constaia key cannot.
Verification screen
import * as ImagePicker from "expo-image-picker";
import { useState } from "react";
import { ActivityIndicator, Alert, Button, Image, Text, View } from "react-native";
import { getSessionToken } from "@/lib/session";
type VerdictStatus = "valid" | "invalid" | "review";
interface AnalysisResponse {
status: "queued" | "processing" | "completed" | "failed";
verdict: { status: VerdictStatus; reasons: { severity: string; message: string }[] } | null;
error?: { message: string };
}
type Outcome =
| { kind: VerdictStatus; messages: string[] }
| { kind: "pending" }
| { kind: "error"; message: string };
const API_URL = process.env.EXPO_PUBLIC_API_URL;
const TEST_FILE_NAME: string | undefined = __DEV__ ? "dni_valid.jpg" : undefined;
export default function VerifyScreen() {
const [photo, setPhoto] = useState<ImagePicker.ImagePickerAsset | null>(null);
const [sending, setSending] = useState(false);
const [outcome, setOutcome] = useState<Outcome | null>(null);
async function takePhoto() {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (!permission.granted) {
Alert.alert("Permission needed", "Enable camera access in the phone settings.");
return;
}
const picked = await ImagePicker.launchCameraAsync({ mediaTypes: ["images"], quality: 0.8 });
if (picked.canceled) return;
setOutcome(null);
setPhoto(picked.assets[0]);
}
async function pickFromLibrary() {
const picked = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], quality: 0.8 });
if (picked.canceled) return;
setOutcome(null);
setPhoto(picked.assets[0]);
}
async function upload() {
if (!photo) return;
setSending(true);
try {
const form = new FormData();
form.append("file", {
uri: photo.uri,
name: TEST_FILE_NAME ?? photo.fileName ?? "document.jpg",
type: photo.mimeType ?? "image/jpeg",
} as unknown as Blob);
const res = await fetch(`${API_URL}/api/constaia`, {
method: "POST",
headers: { Authorization: `Bearer ${await getSessionToken()}` },
body: form,
});
const body = (await res.json()) as AnalysisResponse;
if (!res.ok) {
setOutcome({ kind: "error", message: body.error?.message ?? "The document could not be verified." });
} else if (body.status !== "completed" || !body.verdict) {
setOutcome({ kind: "pending" });
} else {
setOutcome({
kind: body.verdict.status,
messages: body.verdict.reasons.filter((r) => r.severity !== "info").map((r) => r.message),
});
}
} catch {
setOutcome({ kind: "error", message: "No connection. Please try again." });
} finally {
setSending(false);
}
}
return (
<View style={{ flex: 1, padding: 16, gap: 12 }}>
<Text style={{ fontSize: 20, fontWeight: "600" }}>Photograph your document</Text>
<Text>On a flat surface, in good light and without glare. Make sure the whole document is visible.</Text>
{photo && <Image source={{ uri: photo.uri }} style={{ width: "100%", aspectRatio: 1.586, borderRadius: 8 }} />}
<Button title={photo ? "Retake photo" : "Take photo"} onPress={takePhoto} disabled={sending} />
<Button title="Choose from library" onPress={pickFromLibrary} disabled={sending} />
{photo && <Button title="Send" onPress={upload} disabled={sending} />}
{sending && <ActivityIndicator />}
{outcome?.kind === "valid" && <Text>Valid document.</Text>}
{outcome?.kind === "pending" && <Text>We are checking it. We will let you know when it is done.</Text>}
{outcome?.kind === "review" && (
<>
<Text>We could not read the photo well.</Text>
{outcome.messages.map((m) => (
<Text key={m}>{m}</Text>
))}
<Button title="Take another photo" onPress={takePhoto} />
</>
)}
{outcome?.kind === "invalid" && (
<>
<Text>The document is not valid:</Text>
{outcome.messages.map((m) => (
<Text key={m}>{m}</Text>
))}
<Button title="Try another document" onPress={takePhoto} />
</>
)}
{outcome?.kind === "error" && <Text>{outcome.message}</Text>}
</View>
);
}- In React Native, a file is appended to
FormDataas{ uri, name, type }. Do not setContent-Type:fetchgenerates it with the multipartboundary. getSessionToken()is yours: your user's session token, which your backend validates.reviewmeans the image is not reliable (blurry, cropped, glare…). Ask for another photo instead of rejecting the user. If it keeps happening, route it to manual review.invalidcomes with concrete reasons, such asnot_expired(expired) ortype_mismatch(a different document type).
With expo-camera
If you prefer a camera embedded in the screen, expo-camera also returns a uri and the upload is the same:
const picture = await cameraRef.current?.takePictureAsync({ quality: 0.8 });
if (picture) form.append("file", { uri: picture.uri, name: "document.jpg", type: "image/jpeg" } as unknown as Blob);Two sides in one file
The API takes a single file per analysis. A passport fits in one photo, but the Spanish DNI and NIE have two sides: if you send only one, the analysis may return the side_missing warning and end in review. For those documents, ask for two photos and combine them on your backend into one image (one side above the other) or a two-page PDF before calling Constaia; it still costs 1 credit.
3. Test mode
With the ck_test_... key on the backend no credits are spent and the response depends on the file name. Camera photos have random names, so in development (__DEV__) the screen sends the photo under the TEST_FILE_NAME name. The photo is real, so the file type is valid. Change the name to try each case:
TEST_FILE_NAME | verdict.status | What the app should do |
|---|---|---|
dni_valid.jpg | Válido | Continue. Reason not_expired (info): "Valid until 12/03/2031." |
dni_expired.jpg | No válido | Show the expiry reason (expired on 15/06/2020) and ask for another document. |
blurry.jpg | Revisar | Ask for another photo. warnings: blurry, low_quality. |
passport.jpg | Válido | Passport valid until 01/06/2032. |
More names in Test mode.
Production
- Key only on the server, never in the app or in
EXPO_PUBLIC_*. - Authenticate and rate-limit
/api/constaiaper user: every call spends credits and an app is easy to automate. - Size: multer already accepts 20 MB; raise your proxy limit too (nginx:
client_max_body_size 25m;). Withquality: 0.8photos are usually well below that. - Timeouts: a synchronous analysis waits up to 30 s. Give the app request and the proxy plenty of room (60 s or more) and show a progress indicator.
- Mobile networks: if the connection drops mid-upload, let the user retry; the backend SDK already retries its own calls without charging twice.
- Decide on the server: the next step of the signup reads the result stored in
saveVerification()(orGET /v1/analyses/{id}), not what the app says.