Constaia
Integrations

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.

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

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-picker 16+ installed). In earlier versions, write mediaTypes as ImagePicker.MediaTypeOptions.Images.
  • Node ≥ 18 for the backend.
  • A ck_test_... test key from the dashboard.

1. Backend (Express)

npm i express multer @constaia/sdk
server/.env
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...
server/server.mjs
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.mjs
  • requireUser (yours) validates the session token the app sends in Authorization and puts the user in res.locals.user. saveVerification(), markEventProcessed() and updateVerification() are your database.
  • The server decides expect and checks. 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 with status: "queued" or "processing"); notify the app with a push notification or let it poll your backend. More in Webhooks and the Express guide.
SDK errorHTTP to the appMeaning
InvalidRequestErrorthe same (400, 409, 413, 415, 422)Invalid photo or request (empty, unsupported format, unreadable…).
RateLimitError429 + Retry-AfterYou exceeded your key's requests per second.
InsufficientCreditsError503No credits: alert your team.
AuthenticationError, PermissionError500Missing, revoked or wrong key.
APITimeoutError504The SDK hit its timeout.
APIError, APIConnectionError502Constaia 5xx or network error.

2. The app

Install and permissions

npx expo install expo-image-picker
app.json
{
  "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."
        }
      ]
    ]
  }
}
.env
EXPO_PUBLIC_API_URL=https://api.your-domain.com

The backend URL can be public; the Constaia key cannot.

Verification screen

app/verify.tsx
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 FormData as { uri, name, type }. Do not set Content-Type: fetch generates it with the multipart boundary.
  • getSessionToken() is yours: your user's session token, which your backend validates.
  • review means 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.
  • invalid comes with concrete reasons, such as not_expired (expired) or type_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_NAMEverdict.statusWhat the app should do
dni_valid.jpgVálidoContinue. Reason not_expired (info): "Valid until 12/03/2031."
dni_expired.jpgNo válidoShow the expiry reason (expired on 15/06/2020) and ask for another document.
blurry.jpgRevisarAsk for another photo. warnings: blurry, low_quality.
passport.jpgVálidoPassport 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/constaia per 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;). With quality: 0.8 photos 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() (or GET /v1/analyses/{id}), not what the app says.

Next steps

Nesta página