Ein Passfoto in strukturierte Daten zu verwandeln, gehört zu den Aufgaben, die nach „eine OCR-API aufrufen“ aussehen und im Produktivbetrieb sofort drei Folgefragen aufwerfen. Was passiert, wenn das Foto unscharf ist? Was passiert, wenn die Anfrage in einen Timeout läuft und Sie es erneut versuchen: Haben Sie gerade doppelt bezahlt? Und woher wissen Ihre eigenen Datensätze, welche Aufrufe Geld gekostet haben?

Dieses Tutorial beantwortet diese drei Fragen mit reinem Node.js 18+ und dem eingebauten fetch, ohne SDK. Es verwendet doc.cheap, und dies ist der Blog von doc.cheap. Begegnen Sie den Produktentscheidungen also mit angemessener Skepsis. Die Muster (Idempotenzschlüssel, Verzweigen nach einem stabilen Fehlercode, ein Kostenflag pro Aufruf) gelten für jede kostenpflichtige API.

Der erste Aufruf, ganz ohne Konto

Die API hat einen öffentlichen Sandbox-Schlüssel, der in ihrer Dokumentation steht: sk_sandbox_public. Er führt dieselbe Erkennung aus wie ein kostenpflichtiger Schlüssel und erfordert keine Registrierung: insgesamt 10 kostenlos erkannte Dokumente pro IP-Adresse und höchstens 10 Anfragen pro Stunde, unabhängig von deren Ergebnis. Das reicht, um alles Folgende auszuprobieren. Eine spätere Registrierung bringt 20 Gratis-Credits, ohne Karte.

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);

Speichern Sie das als first.mjs und führen Sie node first.mjs aus. Der Aufruf ist synchron: keine Job-ID, kein Polling, kein Webhook. Die Erkennung läuft innerhalb der Anfrage, und die Felder kommen in deren Antwort zurück.

Verwenden Sie zum Testen ein synthetisches Muster, nicht Ihren eigenen Pass. Viele Aussteller veröffentlichen Musterseiten, und die fiktiven „Utopia“-Dokumente der ICAO gibt es genau für diesen Zweck.

Die Bildgröße ist wichtiger, als man denkt. Ein Handyfoto direkt aus der Kamera kann mehrere Megabyte groß sein, und base64 bläht es um etwa ein Drittel auf. Die Dokumentation empfiehlt rund 1600 px an der langen Kante bei JPEG-Qualität 85. Wenn sich ein erster Aufruf langsam anfühlt, sehen Sie sich meta.timing.upload_ms an, bevor Sie irgendwelchen Servern die Schuld geben.

Was zurückkommt

Eine JSON-Struktur, acht Gruppen, und jeder Schlüssel ist immer vorhanden. Ein unbekannter Wert ist null, nie ein fehlender Schlüssel. Eine gekürzte Antwort für ein erkanntes Dokument sieht so aus:

{
  "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": [] }
}

(Die Inhaberin ist ein erfundenes Muster aus der Dokumentation; fields und images sind gekürzt.) Was Sie wissen sollten:

  • document und holder sind die aufbereiteten Werte. Datumsangaben folgen ISO 8601, Länder ISO 3166-1 alpha-3. Jede Gruppe ist als Ganzes null, wenn der Scan für sie nichts ergeben hat. Deshalb verwendet das Snippet oben ?..
  • fields[] enthält jeden einzelnen Lesewert mit eigener confidence-Stufe (high, medium, low, keine scheinpräzise Prozentzahl). Ein auf Griechisch gedruckter Name kommt zweimal zurück, einmal griechisch und einmal transliteriert, und der griechische Wert ist mit seiner Sprache gekennzeichnet.
  • mrz.status ist passed, failed oder absent, und mrz.text ist die rohe Zone, damit Sie die Prüfziffern selbst nachrechnen können.
  • authenticity.overall ist not_checked. Das ist Erkennung, keine Fälschungserkennung. Verkaufen Sie es Ihrem Compliance-Team nicht als Identitätsprüfung.

Ein unscharfes Foto ist keine Exception

Das Nützlichste, das Sie verinnerlichen sollten: Ein Foto, das nicht gelesen werden konnte, ist ein 200, kein Fehler. meta.status ist genau einer von fünf Strings:

status Bedeutung Was tun
recognized Erfolgreich gelesen Daten verwenden
no_document_found Nichts Dokumentähnliches im Bild Nutzer bitten, den Ausschnitt neu zu wählen
unreadable Ein Dokument, aber kein verwertbarer Text Besseres Licht, Fokus, Winkel
unsupported_document Gefunden, aber kein Typ, den die API kennt Abbrechen, ein Retry liest dasselbe
rejected Auf der Serviceseite fehlgeschlagen Einmal wiederholen

Ihr Code verzweigt also zweimal: nach HTTP-Status für Fehler und nach meta.status für Ergebnisse. Behandeln Sie no_document_found als Exception, wiederholen Sie ein Foto, das sich nie lesen lassen wird. Behandeln Sie es als Erfolg, speichern Sie ein Dokument ohne Felder.

Wer das unscharfe Foto bezahlt

Jede Antwort enthält meta.billed. Bei einem Live-Schlüssel ist es true, wenn der Dokumenttyp bestimmt und tatsächlich Daten extrahiert wurden: eine MRZ, deren Prüfziffern stimmen, mindestens fünf Felder der bedruckten Zone oder ein korrekt dekodierter Barcode. Alles andere (kein Dokument gefunden, unlesbares Bild, nicht unterstützter Typ, interner Fehler, Timeout) kostet nichts. Der Preis für ein abgerechnetes Dokument beträgt pauschal $0.01, bei jedem Volumen.

Mit keinem der beiden Sandbox-Schlüssel wird überhaupt etwas berechnet. Beim öffentlichen Schlüssel sk_sandbox_public gibt billed trotzdem an, ob derselbe Scan mit einem Live-Schlüssel abgerechnet worden wäre. Genau das macht ihn nützlich, um das Flag zu testen. Der eigene Sandbox-Schlüssel eines Kontos liest Ihr Bild nicht: Er beantwortet jeden Aufruf mit einem fest eingebauten Muster, dort beschreibt billed also dieses Muster.

Das Flag gilt pro Aufruf, also speichern Sie es neben dem Ergebnis. Bei einem Live-Schlüssel ist Ihre eigene Zahl der Zeilen mit billed: true in einem Kalendermonat (UTC) dann dieselbe Zahl, die GET /v1/usage für diesen Monat als scans.billed meldet, ganz ohne Abgleich.

Retries, die nicht doppelt abrechnen

Gefährlich ist der Retry nach einem Timeout: Sie wissen nicht, ob die erste Anfrage angekommen ist. Die API akzeptiert bei POST /v1/scans einen Header Idempotency-Key (1 bis 255 Zeichen). Bei einem Live-Schlüssel liefert ein Retry mit demselben Schlüssel und demselben Body das gespeicherte erste Ergebnis (ohne die Bildausschnitte), statt die Erkennung erneut auszuführen und noch einmal abzurechnen.

Drei Details aus der Referenz, die verändern, wie Sie den Client schreiben:

  • Der Schlüssel wird zusammen mit einem Fingerprint des gesamten Bodys abgeglichen. Gleicher Schlüssel, anderes Bild ergibt 409 idempotency_conflict, keine stille Wiederholung.
  • Ein Schlüssel wird so lange gespeichert wie das Ergebnis. Senden Sie retain_hours: 0 (nichts aufbewahren), bleibt der Schlüssel trotzdem 24 Stunden gespeichert: Ein Retry mit ihm in dieser Zeit erhält 409 idempotency_replay_unavailable, der Scan läuft also nicht zweimal, aber es gibt nichts zurückzugeben. Nach diesen 24 Stunden ist ein Retry mit demselben Schlüssel ein neuer Scan. Keine Aufbewahrung und wiederholbare Retries schließen sich gegenseitig aus. Entscheiden Sie sich also je Anwendungsfall für eines von beiden.
  • Mit dem Sandbox-Schlüssel wird der Header akzeptiert, hat aber keine Wirkung, weil dort nichts abgerechnet wird. Den Codepfad können Sie trotzdem schreiben und testen.

Der vollständige Client

Diese Version würden wir in einen Service einbauen. Sie erzeugt einen Schlüssel pro Bild und verwendet ihn bei jedem Retry wieder, wiederholt nur die Fehlercodes, die laut Dokumentation wiederholbar sind, respektiert Retry-After bis zu einer Minute und gibt auf, statt länger zu warten, und sie gibt den rohen Scan zurück, damit Ihnen kein Feld verloren geht.

// 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(); // ein Schlüssel für dieses Bild, bei jedem Retry wiederverwendet

  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;
    }

    // Ein Proxy dazwischen kann mit HTML antworten; das behandeln wir wie einen Netzwerkfehler.
    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);
    // Das Stundenlimit der Sandbox verlangt bis zu einer Stunde Wartezeit; so lange
    // innerhalb eines Aufrufs zu warten hilft niemandem, also ist alles über einer Minute ein Fehler.
    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;
  }
}

Starten Sie ihn mit node scan.mjs specimen.jpg. Das hier hat er am 24. September 2026 für einen generierten Testpass mit dem öffentlichen Sandbox-Schlüssel ausgegeben. Die vom Dokument gelesenen Werte sind durch ersetzt; alles andere ist genau so, wie es ausgegeben wurde:

{
  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 }

billed: true bei einem Sandbox-Schlüssel ist keine Abbuchung: Es besagt, dass dieser Scan mit einem Live-Schlüssel einen Credit gekostet hätte.

Einige Entscheidungen, die eine Erklärung verdienen:

  • fetch wirft bei 4xx oder 5xx keine Exception. Es wirft nur bei Netzwerkfehlern. Deshalb prüft der Client response.ok und liest den JSON-Fehlerbody in jedem Fall.
  • Verzweigen Sie nach error.code, nie nach dem HTTP-Status. Bei POST /v1/scans teilen sich drei Idempotenz-Codes den Status 409 und drei verschiedene Ausfälle den Status 503, und sie brauchen jeweils eine andere Behandlung. Jeder Fehlerbody hat dieselbe Form: code, message, docs_url, request_id, event_id. Loggen Sie die request_id; die braucht der Support. Die vollständige Tabelle steht unter handle errors.
  • Nicht alles ist wiederholbar. validation_failed, payload_too_large, unauthorized und insufficient_credits brauchen eine Korrektur, keine Schleife. In der Sandbox begegnen Ihnen außerdem registration_required (das Gratiskontingent ist aufgebraucht; Warten füllt es nicht wieder auf) und document_repeated (dasselbe Bild wurde innerhalb einer Stunde zu oft gesendet).
  • Loggen Sie den Request-Body nicht. Er ist ein Ausweisdokument.
  • reference wird als meta.reference zurückgegeben (bis zu 128 Zeichen). So verknüpfen Sie einen Scan am einfachsten mit Ihrem eigenen Auftrag oder Nutzerdatensatz. Die zwei Seiten eines Ausweises sind zwei Aufrufe; geben Sie beiden dieselbe reference.

Weniger Daten aufbewahren

Das hochgeladene Bild wird für die Dauer der Anfrage im Arbeitsspeicher gehalten und nie dauerhaft gespeichert. Beim Ergebnis ist das anders: Es wird aufbewahrt, damit Sie es mit GET /v1/scans/{id} erneut abrufen können, für einen Zeitraum, den Sie wählen. Die Kontoeinstellung bietet 24 Stunden, 7 Tage, 30 Tage oder ein Jahr, und die Voreinstellung für ein neues Konto ist ein Jahr. Pro Anfrage akzeptiert options.retain_hours Werte von 0 bis 8760; 0 schreibt überhaupt keine Zeile. Wenn Sie das JSON nur einmal brauchen, senden Sie retain_hours: 0 und nehmen Sie den oben beschriebenen Kompromiss bei der Idempotenz in Kauf. Die Verarbeitung findet in der EU statt.

return_portrait: false, wie im Client oben verwendet, lässt images.main_photo weg, den Ausschnitt mit dem Foto der Inhaberin oder des Inhabers. Das ist eine Sache weniger, mit der Sie in Ihren eigenen Logs und Speichern vorsichtig umgehen müssen. Der Ausschnitt der ganzen Seite kommt weiterhin zurück.

Live gehen

Ersetzen Sie sk_sandbox_public über DOC_CHEAP_API_KEY durch Ihren eigenen Schlüssel, und sonst ändert sich nichts: derselbe Endpunkt, dieselbe Struktur. Ein registrierter Schlüssel erlaubt 60 Anfragen pro Minute. Credits werden mit Kryptowährung gekauft (BTC, ETH, TRX oder USDT auf Ethereum oder Tron), mit einem Minimum von $1; eine Kartenzahlung gibt es derzeit nicht. Das sollten Sie wissen, bevor Sie eine Demo für ein Finanzteam planen. Unter Preise finden Sie den einen Preis und die Regel, dass nur bei Erfolg abgerechnet wird, und die Seite zur kostenlosen Reisepass-OCR-API zeigt den ersten Aufruf ohne Registrierung als einzelnen curl-Befehl.

Wenn Sie es ausprobieren und etwas an der Antwortstruktur in Node umständlich zu handhaben ist, schreiben Sie an admin@doc.cheap. Genau dieses Feedback suchen wir.

Der Client oben wurde gegen die echte Sandbox ausgeführt, und seine Ausgabe ist so wiedergegeben, wie sie ausgegeben wurde; jede Aussage über doc.cheap wurde am Code überprüft.