Read a passport in an Ionic app

An Ionic app can read a passport in three steps. The phone takes the photo with the Capacitor Camera plugin. It sends the photo to a small server of your own. That server calls POST /v1/scans with your key and hands the fields back to the app.

The server in the middle is not optional. The next section says why.

Keep the key off the phone

Never put your API key in the app. Everything inside an app package can be read. The JavaScript bundle of an Ionic app is a folder of plain files. Pulling a string out of an APK or an IPA takes minutes with free tools. Once someone has your key, every recognition they run is charged to your account, and you cannot tell their calls from yours.

The key lives only on a server you control, read from an environment variable. The app talks to that server, and the server talks to us. The same rule holds for a web build of the same app, where the key would sit in a file any visitor downloads.

The examples use the public sandbox key sk_sandbox_public, which is safe to print because it is shared on purpose. It allows 10 free recognitions per client and 10 requests per hour. For your own key, with 100 free recognitions every month, see from the sandbox to a live key.

1. The server

This is a complete Express server with one route. Any backend works the same way: a serverless function, a route in a server you already run, another language. It needs Node 18 or later, for the built-in fetch.

npm install express cors
DOC_CHEAP_KEY=sk_sandbox_public node server.mjs
// server.mjs
import express from "express";
import cors from "cors";

const DOC_CHEAP_KEY = process.env.DOC_CHEAP_KEY;
if (!DOC_CHEAP_KEY) {
  throw new Error("Set DOC_CHEAP_KEY before starting the server");
}

const app = express();

// The origins a Capacitor app sends from: iOS, Android, and `ionic serve`.
app.use(
  cors({
    origin: ["capacitor://localhost", "https://localhost", "http://localhost:8100"],
  }),
);
app.use(express.json({ limit: "10mb" }));

app.post("/scan", async (req, res) => {
  // Check here that the request comes from a signed-in user of your app.
  // Without it, anyone who finds this address spends your recognitions.

  const image = req.body?.image;
  if (typeof image !== "string" || image.length === 0) {
    res.status(400).json({ error: "Send the photo as base64 in `image`." });
    return;
  }

  const upstream = await fetch("https://api.doc.cheap/v1/scans", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${DOC_CHEAP_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ image }),
  });
  const scan = await upstream.json();

  if (!upstream.ok) {
    res.status(upstream.status).json({ error: scan.error.code, message: scan.error.message });
    return;
  }

  // Send the app only what it shows.
  res.json({
    status: scan.meta.status,
    document: scan.document,
    holder: scan.holder,
    mrz: scan.mrz.status,
  });
});

app.listen(3000, () => console.log("Listening on http://localhost:3000"));

Three things in it are deliberate.

Try it from your computer before the phone is involved. photo.jpg is any picture.

curl -X POST http://localhost:3000/scan \
  -H "Content-Type: application/json" \
  -d "{\"image\": \"$(base64 < photo.jpg | tr -d '\n')\"}"

A picture without a document answers 200 with "status": "no_document_found" or "unsupported_document". That is a finished scan, not an error, and it costs nothing.

2. Take the photo in the app

Install the Camera plugin in your Ionic project. On iOS, the app's Info.plist also needs NSCameraUsageDescription, the sentence the system shows when it asks for camera access.

npm install @capacitor/camera
npx cap sync

The function below takes the photo, sends it to your server and returns what the server answered. It does not depend on Angular, React or Vue, so you can call it from any of them.

// src/scan.ts
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";

// Your own server from step 1. On a phone, `localhost` is the phone itself:
// use your computer's address on the local network while you develop.
const BACKEND_URL = "http://192.168.1.20:3000";

export interface PassportReading {
  status: "recognized" | "no_document_found" | "unreadable" | "unsupported_document" | "rejected";
  document: {
    kind: string | null;
    country: string | null;
    type_name: string | null;
    number: string | null;
    expiry_date: string | null;
    is_expired: boolean | null;
  } | null;
  holder: {
    given_names: string | null;
    surname: string | null;
    full_name: string | null;
    birth_date: string | null;
    nationality: string | null;
  } | null;
  mrz: "passed" | "failed" | "absent";
}

export async function readPassport(): Promise<PassportReading> {
  const photo = await Camera.getPhoto({
    source: CameraSource.Camera,
    resultType: CameraResultType.Base64,
    quality: 85,
    width: 1600,
    correctOrientation: true,
  });

  const response = await fetch(`${BACKEND_URL}/scan`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ image: photo.base64String }),
  });

  const body = await response.json();
  if (!response.ok) {
    throw new Error(body.message ?? body.error);
  }
  return body as PassportReading;
}

The camera options matter.

Version 8.1 of the Camera plugin marks getPhoto as deprecated in favour of takePhoto. It still works in every current version, and it returns the full photo as base64 in one call.

During development, the phone and the computer must be on the same network, and Android refuses plain http addresses unless you allow them. In production, put the server behind https and use its real address.

3. Show the fields

Every key of the answer is always present, and a value that was not read is null. The app checks status once, then reads the fields with one null check on document and holder.

// src/show.ts
import { readPassport } from "./scan";

export async function onScanClick(): Promise<string> {
  const reading = await readPassport();

  if (reading.status !== "recognized") {
    return "No passport found in the photo. Try again with the whole page in the frame.";
  }

  const { document, holder } = reading;
  return [
    `Name: ${holder?.full_name ?? "not read"}`,
    `Passport: ${document?.number ?? "not read"}`,
    `Expires: ${document?.expiry_date ?? "not read"}${document?.is_expired ? " (expired)" : ""}`,
    `Machine-readable zone: ${reading.mrz}`,
  ].join("\n");
}

Dates arrive as YYYY-MM-DD and countries as three-letter codes such as GRC. mrz is passed only when the machine-readable zone was found and every check digit in it is valid. That is the strongest sign that the number and dates were read correctly. The meaning of every field is in the response reference.

What to tell the user

All five values of status arrive under HTTP 200, and they map to four messages.

status What happened What the app says
recognized The document was read Show the fields
no_document_found, unsupported_document No passport in the frame, or a document type this service does not read Ask for a new photo of the passport's photo page
unreadable A document was found, but no usable text came off it Ask for a sharper photo, without glare
rejected Recognition failed on our side Ask the user to try again in a moment

A photo with no document in it costs nothing. Whether a given scan drew a credit is meta.billed, not status, and what a billed scan is gives the rule.

Next