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.
- It checks who is calling. The comment marks where your own sign-in check goes. A route that forwards anyone's photo with your key is the same leak as a key in the app, one step removed.
- It returns a short answer. The full response also carries every printed field, the image crops and the quality checks. The app above needs four things, so the server sends four.
- It passes errors on with their code. A non-2xx answer from the API always
has the same
errorenvelope, and itscodeis what to branch on. What to do with each code is in handle errors.
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 syncThe 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.
CameraResultType.Base64gives the bare base64 text the API expects.DataUrlwould add adata:image/jpeg;base64,prefix, which the API refuses withvalidation_failed.width: 1600andquality: 85match the advice in recognize a passport: print stays sharp, and the upload stays in the hundreds of kilobytes instead of several megabytes.correctOrientation: trueturns a photo taken in portrait the right way up before it is sent.
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¶
- Crop a document with OpenCV – prepare the photo on a server before you send it.
- Handle errors – the codes your server can pass on.
- Response – every field in the answer.