Passport OCR
Skip the camera work — our free open-source eKYC SDK (Web, Flutter, iOS, Android & React Native) auto-captures documents and faces and calls this API for you. View on GitHub.
The Passport OCR API reads the machine-readable zone (MRZ) of any ICAO 9303 passport and returns the holder's details — number, names, dates, nationality — together with the face photograph and a set of verification flags. A passport MRZ is self-validating: every key field carries its own check digit, and the valid_* flags report whether each returned value satisfies it, so a read can be trusted arithmetically rather than on faith. The production service answers in 0.4 seconds (median) and sustains 15,000 passports per hour; on the 266-passport benchmark, 95.5 percent of reads return the number, date of birth, and expiry all verified against their check digits, and 100 percent of MRZs parse. See Accuracy.
Live Demo
Upload a passport photo page, or use the specimen below.
Try Our AI Demo
Login or create a free account to use this AI service demo and explore our powerful APIs.
Get 100 Free Credits (IC) when you sign up!
Offer ends December 31, 2025
Try the SDK (Live Camera)
Prefer not to upload a file? The free open-source iApp eKYC Web SDK captures the passport data page automatically from your camera — it detects the page boundary, waits for a sharp stable frame, perspective-corrects the image, and submits it to this API. See more flows on the full SDK live demo page.
Loading live demo…
Quick Start
You need an API key from the API Key Management page. Send the image as multipart/form-data:
curl -X POST https://api.iapp.co.th/v3/store/ekyc/passport \
-H "apikey: YOUR_API_KEY" \
-F "file=@passport.jpg"
{
"number": "AC1062346",
"valid_number": true,
"surname": "POSHNASWADIWONG",
"names": "MATHANIDA",
"date_of_birth": "10/07/93",
"valid_date_of_birth": true,
"expiration_date": "04/03/25",
"valid_expiration_date": true,
"nationality": "THA",
"valid_composite": true
}
valid_number is the field to act on: when it is false, the returned number cannot belong to any real passport and the image should be re-captured rather than the value trusted. The full response schema is in the Technical Reference.
Endpoints and Pricing
| Endpoint | Output | Price |
|---|---|---|
POST /v3/store/ekyc/passport | JSON with holder details, check-digit verification flags, and the face photograph | 0.75 IC per page |
The legacy paths /passport-ocr, /passport-ocr/v2, and /passport-ocr/v2/ocr remain supported at the same price and reach the same service. For on-premise deployment, see Data Security.
Performance
Measured on the production service, August 2026.
| Metric | Value |
|---|---|
| Median processing time | 0.4 s per passport |
| Sustained throughput | 4.3 passports per second (15,000 passports per hour) |
| Supported input formats | JPEG, JPG, PNG, WEBP, PDF (one result per page) |
| Coverage | Any ICAO 9303 machine-readable passport |
Accuracy
A passport MRZ carries its own check digits, so correctness is measured arithmetically on the 266-passport benchmark — no reference transcription and no judgement calls: a returned field either satisfies the check digit printed on the passport or it does not.
| Measure | v2.0.3 |
|---|---|
| MRZ parsed | 100% |
| Document number verified | 96.2% |
| Date of birth verified | 99.2% |
| Expiry date verified | 98.1% |
| Personal number verified | 98.5% |
| Number, birth date, and expiry all verified | 95.5% |
The August 2026 release raised the fully-verified rate from 58.6 percent to 95.5 percent — a returned document number that fails its check digit cannot be real, and such reads dropped from 30 percent of passports to under 4. The measure is deliberately conservative: a check digit is one decimal digit, so roughly one wrong read in ten passes it by coincidence, meaning true accuracy is slightly below these figures in any release measured this way. Full methodology, the engine and validation contributions measured separately, and the safety properties of the repair logic are published in the whitepaper:
Download the benchmark whitepaper (PDF)
Data Security and Compliance
- The service is GDPR and PDPA compliant.
- Uploaded images are processed in memory and are not retained after the response is returned.
- A fully self-contained on-premise deployment is available, in which no passport data leaves your infrastructure. Contact us for details.
Technical Reference
Request
POST with multipart/form-data and the apikey header.
| Parameter | Required | Description |
|---|---|---|
file | Yes | Passport image or PDF (JPG, JPEG, PNG, WEBP, PDF). A PDF returns one entry per page, each with page and info |
fields | No | Comma-separated subset of fields to return; all are returned if omitted |
options | No | Comma-separated. segmentation corrects badly oriented images (roughly 2× slower); no_thresh skips pre-processing of the MRZ crop |
Response
200 with a flat JSON object.
Holder details
| Field | Description |
|---|---|
number | Passport number |
surname | Surname, as printed in the MRZ |
names | Given names |
date_of_birth | DD/MM/YY |
expiration_date | DD/MM/YY |
sex | M, F, or < if unspecified |
nationality | ISO 3166-1 alpha-3 |
country | Issuing country, ISO 3166-1 alpha-3 |
personal_number | Optional personal number; blank on many passports |
type | Document type as printed in the MRZ, P< for an ordinary passport |
mrz_type | MRZ layout, TD3 for passports |
face | Face photograph, Base64 JPEG |
Verification — whether each field agrees with its check digit, and the check digits as printed.
| Field | Description |
|---|---|
valid_number | Passport number agrees with its check digit |
valid_date_of_birth | Date of birth agrees with its check digit |
valid_expiration_date | Expiry date agrees with its check digit |
valid_personal_number | Personal number agrees with its check digit |
valid_composite | The MRZ as a whole agrees with its composite check digit |
valid_score | Percentage of the check digits that verified, 0–100 |
check_number, check_date_of_birth, check_expiration_date, check_personal_number, check_composite | The printed check digits |
Diagnostics
| Field | Description |
|---|---|
raw_text | The two MRZ lines, 44 characters each |
method | How the MRZ band was located |
inference | Processing time in seconds |
file_name, message, status_code | Success / 200 on the happy path |
When no MRZ can be located the request still returns 200 with an Error Message field; options=segmentation usually resolves badly oriented images.
Response Codes
| Status Code | Description |
|---|---|
| 200 | Success (including no-MRZ-found, reported in the body) |
| 415 | Unsupported file type |
| 461 | No file attached |
| 462 | Request form did not contain a file key |
Code Examples
- cURL
- Python
- JavaScript
- PHP
- Swift
- Kotlin
- Java
- Dart
curl -X POST https://api.iapp.co.th/v3/store/ekyc/passport \
-H "apikey: YOUR_API_KEY" \
-F "file=@passport.jpg"
import requests
url = "https://api.iapp.co.th/v3/store/ekyc/passport"
headers = {"apikey": "YOUR_API_KEY"}
files = {"file": open("passport.jpg", "rb")}
response = requests.post(url, headers=headers, files=files)
result = response.json()
if not result.get("valid_number"):
print("Re-capture: the number failed its check digit")
print(result)
const axios = require("axios");
const FormData = require("form-data");
const fs = require("fs");
const data = new FormData();
data.append("file", fs.createReadStream("passport.jpg"));
axios.post("https://api.iapp.co.th/v3/store/ekyc/passport", data, {
headers: { apikey: "YOUR_API_KEY", ...data.getHeaders() },
})
.then((response) => console.log(response.data))
.catch((error) => console.log(error));
<?php
$curl = curl_init();
curl_setopt_array($curl, array(
CURLOPT_URL => 'https://api.iapp.co.th/v3/store/ekyc/passport',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => array(
'file' => new CURLFILE('passport.jpg')
),
CURLOPT_HTTPHEADER => array(
'apikey: YOUR_API_KEY'
),
));
$response = curl_exec($curl);
curl_close($curl);
echo $response;
?>
import Foundation
let url = URL(string: "https://api.iapp.co.th/v3/store/ekyc/passport")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("YOUR_API_KEY", forHTTPHeaderField: "apikey")
let boundary = "Boundary-\(UUID().uuidString)"
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"passport.jpg\"\r\n".data(using: .utf8)!)
body.append("Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!)
if let fileData = try? Data(contentsOf: URL(fileURLWithPath: "passport.jpg")) {
body.append(fileData)
}
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data {
print(String(data: data, encoding: .utf8)!)
}
}.resume()
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
val client = OkHttpClient()
val file = File("passport.jpg")
val requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("file", file.name, file.asRequestBody("image/jpeg".toMediaTypeOrNull()))
.build()
val request = Request.Builder()
.url("https://api.iapp.co.th/v3/store/ekyc/passport")
.addHeader("apikey", "YOUR_API_KEY")
.post(requestBody)
.build()
client.newCall(request).execute().use { response ->
println(response.body?.string())
}
import okhttp3.*;
import java.io.File;
OkHttpClient client = new OkHttpClient();
File file = new File("passport.jpg");
RequestBody requestBody = new MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("file", file.getName(),
RequestBody.create(MediaType.parse("image/jpeg"), file))
.build();
Request request = new Request.Builder()
.url("https://api.iapp.co.th/v3/store/ekyc/passport")
.addHeader("apikey", "YOUR_API_KEY")
.post(requestBody)
.build();
try (Response response = client.newCall(request).execute()) {
System.out.println(response.body().string());
}
import 'package:http/http.dart' as http;
var request = http.MultipartRequest(
'POST',
Uri.parse('https://api.iapp.co.th/v3/store/ekyc/passport'),
);
request.files.add(await http.MultipartFile.fromPath('file', 'passport.jpg'));
request.headers.addAll({'apikey': 'YOUR_API_KEY'});
var response = await request.send();
print(await response.stream.bytesToString());
Limitations
- Reads the machine-readable zone only; visual-zone text outside the MRZ is not extracted.
- Two-digit years in the
DD/MM/YYdates are a property of the MRZ standard, which does not encode the century. - Badly oriented or skewed captures may need
options=segmentation, at roughly double the processing time.
Changelog
| Version | Date | Changes |
|---|---|---|
| v2.0.5-20260825 | 25 Aug 2026 | The public endpoint now serves the unified passport service. Dates return as DD/MM/YY — the format this service has returned to its integrators since 2023; callers that parsed the interim YYMMDD form must update. Adds type, mrz_type, method, and valid_score (percentage of check digits verified). Sustained throughput increased to 15,000 passports per hour. |
| v2.0.3-20260825 | 25 Aug 2026 | Upgraded recognition and added structural MRZ validation. Fully check-digit-verified reads (number, birth date, and expiry together) rose from 58.6% to 95.5% on the 266-passport benchmark, and every MRZ now parses. Median processing time reduced from 0.26 s to 0.2 s. The documented valid_date_of_birth and valid_expiration_date fields are now actually returned. Fully self-contained on-premise deployment available. |
| v2.0 | Feb 2023 | MRZ extraction with face image and check-digit flags. |
