パスポートの写真を構造化データに変換する作業は、「OCR APIを呼べば済む」ように見えて、本番で動かしたとたんに3つの疑問が出てくる類いの仕事です。写真がぼやけていたらどうなるのか。リクエストがタイムアウトしてリトライしたら、二重に支払ったことになるのか。そして、どの呼び出しにお金がかかったのかを自分の記録からどう知るのか。

このチュートリアルでは、SDKを使わず、Node.js 18以降と組み込みの fetch だけでこの3つに答えます。使うのはdoc.cheapで、これはdoc.cheap自身のブログです。製品の選択については、それなりに疑ってかかってください。ここで使うパターン(冪等性キー、安定したエラーコードでの分岐、呼び出しごとのコストフラグの保存)は、どの有料APIにも当てはまります。

アカウントなしで最初の呼び出し

このAPIには、ドキュメントに掲載された公開sandboxキー sk_sandbox_public があります。有料キーと同じ認識処理を実行し、登録は不要です。無料で認識できる書類はIPアドレスごとに合計10件まで、リクエストは結果にかかわらず1時間に最大10件です。以下の内容をすべて試すには十分です。あとで登録すると、カードなしで20クレジットが無料で追加されます。

import { readFileSync } from "node:fs";

const image = readFileSync("specimen.jpg").toString("base64");

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

const scan = await response.json();
console.log(scan.meta.status, scan.holder?.full_name, scan.mrz.status);

first.mjs として保存し、node first.mjs を実行します。呼び出しは同期型です。ジョブIDもポーリングもWebhookもありません。認識はリクエストの中で行われ、フィールドはそのレスポンスで返ってきます。

テストには自分のパスポートではなく、合成の見本を使ってください。多くの発行機関が見本ページを公開しており、ICAOの架空の「Utopia」の書類はまさにこの目的のために存在します。

画像サイズは思った以上に効いてきます。カメラで撮ったままのスマートフォンの写真は数メガバイトになることがあり、base64にすると約3分の1大きくなります。ドキュメントでは、長辺約1600 px、JPEG品質85を推奨しています。最初の呼び出しが遅いと感じたら、誰かのサーバーを疑う前に meta.timing.upload_ms を確認してください。

返ってくるもの

JSONの形は1つだけで、グループは8つ、キーは常にすべて存在します。値が不明なときは null になり、キーが欠けることはありません。認識に成功したレスポンスを一部省略して示すと、次のようになります。

{
  "meta": {
    "schema_version": "1.0",
    "id": "01a0af18-cd8d-7a61-9f2d-4c7b8e105da3",
    "status": "recognized",
    "billed": true,
    "confidence": "high",
    "timing": { "upload_ms": 214, "processing_ms": 843, "total_ms": 1074 },
    "created_at": "2026-09-17T09:41:12Z",
    "reference": null
  },
  "document": {
    "kind": "passport", "country": "GRC", "country_name": "Greece",
    "number": "AM7304518", "issue_date": "2022-03-10", "expiry_date": "2032-03-10",
    "is_expired": false, "days_remaining": 2001
  },
  "holder": {
    "given_names": "ELENI SOFIA", "surname": "PARADEIGMA", "full_name": "PARADEIGMA ELENI SOFIA",
    "birth_date": "1994-03-08", "sex": "F", "nationality": "GRC"
  },
  "fields": [],
  "mrz": { "status": "passed", "reason": null, "lines": ["P<GRC…", "AM7304518…"], "text": "P<GRC…" },
  "images": { "document_crop": "data:image/jpeg;base64,…", "main_photo": "data:image/jpeg;base64,…" },
  "quality": { "overall": "pass" },
  "authenticity": { "overall": "not_checked", "checks": [] }
}

(所持人はドキュメントに載っている架空の見本です。fieldsimages は省略しています。)知っておくべき点は次のとおりです。

  • documentholder は整理済みの値です。日付はISO 8601、国はISO 3166-1 alpha-3です。スキャンでそのグループについて何も得られなかったときはグループ全体が null になります。上のコードで ?. を使っているのはそのためです。
  • fields[] は個々の読み取り結果をすべて含み、それぞれに独自の confidence の段階(highmediumlow)があります。見せかけの精度を持つパーセンテージではありません。ギリシャ文字で印字された名前は、ギリシャ文字のままと翻字したものの2回返され、ギリシャ文字の読み取り結果にはその言語のタグが付きます。
  • mrz.statuspassedfailedabsent のいずれかで、mrz.text は領域の生データです。チェックディジットを自分で再計算できます。
  • authenticity.overallnot_checked です。これは認識であって、偽造検出ではありません。コンプライアンス部門に本人確認(ID verification)として説明しないでください。

ぼやけた写真は例外ではない

いちばん身につけておくべきことはこれです。読み取れなかった写真は 200 であり、エラーではありませんmeta.status は次の5つの文字列のいずれかです。

status 意味 対応
recognized 正常に読み取れた データを使う
no_document_found 画像内に書類らしきものがない ユーザーに撮り直しを依頼する
unreadable 書類はあるが、使える文字がない 明るさ、ピント、角度を改善する
unsupported_document 見つかったが、APIが知らない種類 中止する。リトライしても結果は同じ
rejected サービス側で失敗した 1回だけリトライする

つまり、コードは2回分岐します。エラーはHTTPステータスで、結果は meta.status で分岐します。no_document_found を例外として扱うと、決して読み取れない写真をリトライし続けることになります。成功として扱うと、フィールドが空の書類を保存することになります。

ぼやけた写真の料金は誰が払うのか

どのレスポンスにも meta.billed が含まれます。本番キーでは、書類の種類が特定され、実際にデータが抽出されたときに true になります。具体的には、チェックディジットが一致するMRZ、印字領域の5つ以上のフィールド、または正しくデコードされたバーコードのいずれかです。それ以外(書類が見つからない、画像が読み取れない、未対応の種類、内部エラー、タイムアウト)はすべて無料です。課金される書類の料金は、量にかかわらず一律 $0.01 です。

どちらのsandboxキーでも、料金は一切かかりません。公開キー sk_sandbox_public では、それでも billed が、同じスキャンが本番キーなら課金されたかどうかを示します。だからこそ、このキーでフラグをテストできます。アカウント専用のsandboxキーは送った画像を読み取らず、すべての呼び出しに組み込みの見本1件で答えるので、そこでの billed はその見本についての値です。

このフラグは呼び出しごとのものなので、結果と一緒に保存してください。本番キーであれば、暦月(UTC)の billed: true の行数を自分で数えた値が、GET /v1/usage がその月の scans.billed として返す数値と一致します。突き合わせの手順は要りません。

二重課金にならないリトライ

危険なのはタイムアウト後のリトライです。最初のリクエストが届いたかどうかがわからないからです。このAPIは POST /v1/scansIdempotency-Key ヘッダー(1~255文字)を受け付けます。本番キーでは、同じキーと同じボディでリトライすると、認識を再実行して再び課金する代わりに、保存済みの最初の結果(画像の切り抜きは除く)を返します。

クライアントの書き方を左右する、リファレンスの3つの詳細:

  • キーは、ボディ全体のフィンガープリントと組み合わせて照合されます。同じキーで別の画像を送ると 409 idempotency_conflict になり、黙って前の結果が返されることはありません。
  • キーは結果が保存されている間記憶されます。retain_hours: 0(何も保存しない)を送った場合でも、キーは24時間記憶されます。その間に同じキーでリトライすると 409 idempotency_replay_unavailable が返り、スキャンが二重に実行されることはありませんが、返せるものがありません。24時間を過ぎると、同じキーでのリトライは新しいスキャンになります。保存ゼロとリプレイ可能なリトライは両立しないので、ユースケースごとにどちらかを選んでください。
  • sandboxキーではヘッダーは受け付けられますが、効果はありません。sandboxでは何も課金されないからです。それでも、そのコードパスを書いてテストすることはできます。

クライアントの全体

これが、私たちならサービスに組み込むバージョンです。画像ごとにキーを1つ生成してすべてのリトライで再利用し、ドキュメントでリトライ可能とされているエラーコードだけをリトライし、Retry-After には1分まで従い、それ以上待つよりは諦め、すべてのフィールドを残せるようにスキャン結果をそのまま返します。

// scan.mjs
import { readFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";

const API = "https://api.doc.cheap/v1/scans";
const KEY = process.env.DOC_CHEAP_API_KEY ?? "sk_sandbox_public";
const RETRY = new Set([
  "rate_limited", "document_repeated", "internal_error", "engine_unavailable",
  "service_unavailable", "maintenance", "idempotency_in_progress",
]);

export class ScanError extends Error {
  constructor(status, error) {
    super(`${error.code} (${status}): ${error.message}`);
    this.code = error.code;
    this.docsUrl = error.docs_url;
    this.requestId = error.request_id;
  }
}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export async function scan(path, { reference = null, attempts = 4 } = {}) {
  const image = (await readFile(path)).toString("base64");
  const body = JSON.stringify({ image, reference, options: { return_portrait: false } });
  const idempotencyKey = randomUUID(); // この画像に1つのキーを割り当て、すべてのリトライで再利用する

  for (let attempt = 1; ; attempt++) {
    let response;
    try {
      response = await fetch(API, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${KEY}`,
          "Content-Type": "application/json",
          "Idempotency-Key": idempotencyKey,
        },
        body,
        signal: AbortSignal.timeout(60_000),
      });
    } catch (networkError) {
      if (attempt >= attempts) throw networkError;
      await sleep(1000 * 2 ** attempt);
      continue;
    }

    // 途中のプロキシがHTMLで応答することがあるので、ネットワークエラーと同様に扱う。
    const payload = await response.json().catch(() => null);
    if (payload === null) {
      if (attempt >= attempts) throw new Error(`HTTP ${response.status} without a JSON body`);
      await sleep(1000 * 2 ** attempt);
      continue;
    }
    if (response.ok) return payload;

    const { error } = payload;
    if (!RETRY.has(error.code) || attempt >= attempts) throw new ScanError(response.status, error);
    // sandboxの1時間あたりの上限では最大1時間の待機を求められるが、1回の呼び出しの中で
    // そこまで待っても誰の役にも立たないので、1分を超える待機はエラーとする。
    const retryAfter = Number(response.headers.get("Retry-After"));
    const wait = retryAfter > 0 ? retryAfter : 2 ** attempt;
    if (wait > 60) throw new ScanError(response.status, error);
    await sleep(1000 * wait);
  }
}

export function summarise({ meta, document, holder, mrz }) {
  if (meta.status !== "recognized") {
    return { ok: false, status: meta.status, billed: meta.billed };
  }
  return {
    ok: true,
    billed: meta.billed,
    confidence: meta.confidence,
    kind: document?.kind ?? null,
    country: document?.country ?? null,
    number: document?.number ?? null,
    expiryDate: document?.expiry_date ?? null,
    isExpired: document?.is_expired ?? null,
    surname: holder?.surname ?? null,
    givenNames: holder?.given_names ?? null,
    birthDate: holder?.birth_date ?? null,
    mrz: mrz.status,
    mrzReason: mrz.reason,
  };
}

if (import.meta.url === `file://${process.argv[1]}`) {
  try {
    const result = await scan(process.argv[2], { reference: "demo-1" });
    console.log(summarise(result), result.meta.timing);
  } catch (err) {
    if (err instanceof ScanError) console.error(err.message, err.docsUrl, err.requestId);
    else throw err;
    process.exitCode = 1;
  }
}

node scan.mjs specimen.jpg で実行します。以下は、2026年9月24日に公開sandboxキーで、生成したテスト用パスポートに対して出力された結果です。書類から読み取った値は に置き換えていますが、それ以外は出力されたとおりです。

{
  ok: true,
  billed: true,
  confidence: 'medium',
  kind: 'passport',
  country: '…',
  number: '…',
  expiryDate: '…',
  isExpired: false,
  surname: '…',
  givenNames: '…',
  birthDate: '…',
  mrz: 'passed',
  mrzReason: null
} { upload_ms: 271, processing_ms: 410, total_ms: 691 }

sandboxキーでの billed: true は課金ではありません。本番キーならこのスキャンに1クレジットかかっていた、という意味です。

いくつかの設計判断について説明します。

  • fetch は4xxや5xxでは例外を投げません。例外になるのはネットワーク障害のときだけなので、クライアントは response.ok を確認し、どちらの場合もJSONのエラーボディを読みます。
  • 分岐は error.code で行い、HTTPステータスでは行わないことPOST /v1/scans では、3つの冪等性関連のコードが同じ 409 を、3つの異なる障害が同じ 503 を共有しており、それぞれ別の対応が必要です。エラーボディはすべて同じ形で、codemessagedocs_urlrequest_idevent_id を持ちます。request_id はログに残してください。サポートが必要とするのはこの値です。一覧表はドキュメントの handle errors ガイド(英語)にあります。
  • すべてがリトライ可能なわけではありませんvalidation_failedpayload_too_largeunauthorizedinsufficient_credits に必要なのは修正であって、ループではありません。sandboxでは、registration_required(無料枠を使い切った状態で、待っても回復しません)と document_repeated(1時間のうちに同じ画像を送りすぎた)にも出会います。
  • リクエストボディはログに残さないこと。本人確認書類そのものだからです。
  • referencemeta.reference(最大128文字)としてそのまま返されます。スキャンを自社の注文やユーザーのレコードと結び付ける簡単な方法です。IDカードの表と裏は2回の呼び出しになるので、同じ reference を付けてください。

保存するデータを減らす

アップロードされた画像はリクエストの間だけメモリ上に保持され、永続ストレージには書き込まれません。一方、結果のほうは事情が違います。GET /v1/scans/{id} で後から読み出せるように、選んだ期間だけ保存されます。アカウント設定では24時間、7日、30日、1年から選べ、新規アカウントの既定値は1年です。リクエストごとに options.retain_hours で0~8760を指定でき、0 なら行を一切書き込みません。JSONが1回だけ必要なら retain_hours: 0 を送り、上で述べた冪等性とのトレードオフを受け入れてください。処理はEU域内で行われます。

上のクライアントで使っている return_portrait: false を指定すると、所持人の顔写真の切り抜きである images.main_photo が省かれます。自社のログやストレージで慎重に扱うべきものが1つ減ります。ページ全体の切り抜きは引き続き返されます。

本番への移行

DOC_CHEAP_API_KEYsk_sandbox_public を自分のキーに置き換えるだけで、ほかは何も変わりません。エンドポイントも形も同じです。登録済みのキーでは1分あたり60リクエストまで送れます。クレジットは暗号資産(BTC、ETH、TRX、またはEthereumかTron上のUSDT)で購入し、最低額は $1 です。現在はカード決済がありません。財務部門向けのデモを計画する前に知っておくべき点です。料金のページには単一の料金と「成功した分だけ課金」のルールが、無料のパスポートOCR APIのページには登録不要の最初の呼び出しが1行のcurlで載っています。

試してみて、レスポンスの形でNodeから扱いにくい点があれば、admin@doc.cheap までお知らせください。まさにそうしたフィードバックを求めています。

上のクライアントは稼働中のsandboxに対して実行し、その出力を表示されたとおりに掲載しています。doc.cheapに関する記述はすべて、そのコードと照合して確認済みです。