Un lecteur de passeports en Python tient en une vingtaine de lignes la première fois, et en une centaine quand il doit tenir en production. Les quatre-vingts lignes de plus ne concernent pas l'OCR. Elles répondent à trois questions que pose toute API payante : que se passe-t-il quand la photo est mauvaise, que se passe-t-il quand une requête expire et que vous la renvoyez, et comment vos propres données savent-elles quels appels ont coûté de l'argent.

Ce tutoriel construit ce script avec requests et rien d'autre. Il utilise doc.cheap, une API d'OCR pour passeports et pièces d'identité, et ceci est le blog de doc.cheap lui-même : pesez les choix de produit en conséquence. Les motifs (une clé d'idempotence par image, un branchement sur un code d'erreur stable, un indicateur de coût enregistré pour chaque appel) s'appliquent à toute API payante que vous appelez depuis Python.

Une requête avec la clé sandbox

La documentation publie une clé sandbox publique, sk_sandbox_public. Elle exécute la même reconnaissance qu'une clé payante et ne demande aucun compte : 10 documents reconnus gratuits par adresse IP au total, et au plus 10 requêtes par heure, quelle que soit la réponse. La page de l'API gratuite d'OCR de passeport présente le même premier appel sous la forme d'une seule commande curl. Un compte ajoute ensuite 100 documents gratuits chaque mois, sans carte bancaire.

import base64
import requests

with open("specimen.jpg", "rb") as f:
    image = base64.b64encode(f.read()).decode("ascii")

response = requests.post(
    "https://api.doc.cheap/v1/scans",
    headers={"Authorization": "Bearer sk_sandbox_public"},
    json={"image": image},
    timeout=60,
)
scan = response.json()
print(response.status_code, scan["meta"]["status"], scan["meta"]["billed"])

json= définit Content-Type: application/json à votre place. L'image voyage en base64 dans le corps, en JPEG ou en PNG. L'appel est synchrone : les champs reviennent dans cette réponse même, sans identifiant de tâche à interroger ni webhook à héberger.

Testez avec un spécimen synthétique, jamais avec votre propre passeport. Les documents fictifs « Utopia » de l'OACI et les pages de spécimens que publient de nombreux émetteurs existent précisément pour cela.

Réduisez d'abord la photo. Une photo de téléphone peut peser plusieurs mégaoctets avant que le base64 n'y ajoute un tiers. Le guide passeport recommande environ 1600 px sur le grand côté, en qualité JPEG 85 ; un corps qui dépasse la limite est refusé avec payload_too_large avant que quoi que ce soit ne s'exécute.

Lire la réponse

Toutes les clés de la réponse sont toujours présentes, et une valeur inconnue vaut None après json(), jamais une clé manquante. Quatre parties déterminent ce que fait ensuite votre code :

  • meta.status indique si le document a été lu. C'est l'une de cinq chaînes : recognized, no_document_found, unreadable, unsupported_document, rejected. Seule la première apporte des données.
  • document et holder contiennent les valeurs mises en forme : document.kind (passport, une carte d'identité, etc.), le pays en ISO 3166-1 alpha-3, le numéro, les dates au format ISO YYYY-MM-DD ; holder.surname, holder.given_names, holder.birth_date. Chaque groupe vaut None dans son ensemble quand l'analyse n'a rien produit pour lui.
  • mrz.status vaut passed, failed ou absent : la zone de lecture automatique a-t-elle été trouvée, et ses chiffres de contrôle concordent-ils. mrz.text est la zone telle qu'elle a été lue, ce qui vous permet de recalculer vous-même les chiffres de contrôle.
  • meta.billed indique si cet appel a été imputé au solde.

Le point qui façonne tout le client : une photo illisible renvoie HTTP 200, pas une erreur. Le code se ramifie donc deux fois : sur le code d'erreur HTTP pour les refus, et sur meta.status pour les résultats. Levez une exception sur no_document_found, et votre boucle de relance renverra une photo qui ne sera jamais lue. Traitez-le comme un succès, et vous enregistrerez un document sans aucun champ.

L'indicateur billed

Avec une clé live, billed vaut True uniquement lorsqu'un document a réellement été reconnu. Rien de trouvé, une image illisible, un type non pris en charge, une panne côté service : rien de tout cela n'est facturé. Un document facturé coûte $0.01, prix fixe, quel que soit le volume ; la comparaison des API d'OCR de passeport place ce tarif à côté des prix que publient d'autres services.

La sandbox ne facture rien du tout. Avec sk_sandbox_public, billed indique tout de même si la même analyse aurait été facturée avec une clé live, et c'est ce qui la rend utile pour les tests. Enregistrez l'indicateur à côté de chaque résultat : additionner vos propres lignes billed sur un mois ne demande alors aucun rapprochement avec une facture.

Des relances qui ne peuvent pas facturer deux fois

La relance risquée est celle qui suit une expiration de délai. Vous ne savez pas si la première requête a atteint le serveur, et sur une API payante, une relance à l'aveugle peut faire payer deux fois une même image.

La solution est un en-tête Idempotency-Key sur POST /v1/scans, de 1 à 255 caractères. Générez-le une fois par image et envoyez la même valeur à chaque tentative. Avec une clé live, une relance avec la même clé et le même corps récupère le premier résultat au lieu d'une seconde reconnaissance, et cette relecture ne coûte rien. Trois points de la référence façonnent le code :

  • La clé est liée au corps. La même clé avec une autre image ou d'autres options donne 409 idempotency_conflict. Construisez le corps une seule fois, hors de la boucle.
  • Une seconde tentative peut arriver pendant que la première tourne encore. C'est 409 idempotency_in_progress : attendez, puis relancez avec la même clé.
  • Pas de résultat conservé, pas de relecture. Avec retain_hours: 0, rien n'est conservé ; une relance sous la même clé dans les 24 heures reçoit donc 409 idempotency_replay_unavailable au lieu d'une seconde exécution. Ne rien conserver et relire un résultat ne vont pas ensemble ; choisissez selon le cas d'usage.

Les clés sandbox acceptent l'en-tête, mais il n'y décide de rien, puisque rien n'est facturé. Ce chemin du code mérite quand même d'être testé.

Quelles erreurs justifient une relance. Tout corps d'erreur a la même forme (code, message, docs_url, request_id, event_id), et c'est sur le code qu'il faut se brancher, car un même statut HTTP peut porter des codes qui demandent des traitements opposés. Le guide gérer les erreurs les classe en groupes :

Codes Que faire
rate_limited, document_repeated, internal_error, engine_unavailable, service_unavailable, maintenance Attendre (en respectant Retry-After), puis relancer
idempotency_in_progress Attendre, relancer avec la même clé
validation_failed, invalid_request, payload_too_large, unsupported_media_type Corriger la requête ; une relance échouera de nouveau
unauthorized, registration_required, insufficient_credits Corriger la clé ou le compte ; attendre ne change rien

Retry-After s'exprime en secondes entières. La limite horaire de la sandbox peut demander presque une heure d'attente, et aucun appelant ne veut qu'un simple appel de fonction dorme aussi longtemps : le client ci-dessous abandonne donc quand l'attente dépasse une minute.

Le client complet

# scan.py
import base64
import os
import time
import uuid

import requests

API = "https://api.doc.cheap/v1/scans"
KEY = os.environ.get("DOC_CHEAP_API_KEY", "sk_sandbox_public")
RETRY = {
    "rate_limited", "document_repeated", "internal_error", "engine_unavailable",
    "service_unavailable", "maintenance", "idempotency_in_progress",
}
MAX_WAIT = 60  # secondes ; un Retry-After plus long est signalé, pas attendu


class ScanError(Exception):
    def __init__(self, status, error):
        super().__init__(f"{error['code']} ({status}): {error['message']}")
        self.code = error["code"]
        self.docs_url = error["docs_url"]
        self.request_id = error["request_id"]


def retry_after(response, attempt):
    try:
        seconds = int(response.headers.get("Retry-After", ""))
    except ValueError:
        seconds = 0
    return seconds if seconds > 0 else 2 ** attempt


def scan(path, reference=None, attempts=4, session=None):
    session = session or requests.Session()
    with open(path, "rb") as f:
        image = base64.b64encode(f.read()).decode("ascii")
    body = {"image": image, "reference": reference, "options": {"return_portrait": False}}
    headers = {
        "Authorization": f"Bearer {KEY}",
        "Idempotency-Key": str(uuid.uuid4()),  # une clé par image, réutilisée à chaque tentative
    }

    for attempt in range(1, attempts + 1):
        last = attempt == attempts
        try:
            response = session.post(API, headers=headers, json=body, timeout=60)
            payload = response.json()
        except (requests.RequestException, ValueError):
            # Un délai dépassé, une connexion coupée ou un proxy qui répond en HTML.
            if last:
                raise
            time.sleep(2 ** attempt)
            continue

        if response.ok:
            return payload
        error = payload["error"]
        if error["code"] not in RETRY or last:
            raise ScanError(response.status_code, error)
        wait = retry_after(response, attempt)
        if wait > MAX_WAIT:
            raise ScanError(response.status_code, error)
        time.sleep(wait)


def summarise(scan):
    meta, document, holder, mrz = scan["meta"], scan["document"], scan["holder"], scan["mrz"]
    if meta["status"] != "recognized":
        return {"ok": False, "status": meta["status"], "billed": meta["billed"]}
    document, holder = document or {}, holder or {}
    return {
        "ok": True,
        "billed": meta["billed"],
        "kind": document.get("kind"),
        "country": document.get("country"),
        "expiry_date": document.get("expiry_date"),
        "surname": holder.get("surname"),
        "birth_date": holder.get("birth_date"),
        "mrz": mrz["status"],
    }


if __name__ == "__main__":
    import sys

    try:
        print(summarise(scan(sys.argv[1], reference="demo-1")))
    except ScanError as err:
        print(err, err.docs_url, err.request_id, file=sys.stderr)
        sys.exit(1)

Lancez-le avec python scan.py specimen.jpg. Quelques choix qui méritent une explication :

  • requests ne lève pas d'exception sur un 4xx ou un 5xx, sauf si vous appelez raise_for_status(). Le client lit le corps JSON dans les deux cas, parce que le corps d'erreur porte le code ; raise_for_status() le jetterait.
  • Le corps et la clé sont construits une seule fois, avant la boucle. C'est ce qui fait de chaque tentative la même requête aux yeux du serveur.
  • Une Session réutilise la connexion d'une relance à l'autre et sur tout un lot d'images. Passez-en une quand vous analysez de nombreux fichiers.
  • return_portrait: False omet le recadrage de la photo du titulaire. Vous recevez le recadrage de la page et les champs, et il y a un visage de moins dans vos logs et votre stockage.
  • reference revient sous la forme meta.reference (jusqu'à 128 caractères) : le moyen simple de rattacher une analyse à votre propre commande ou à votre utilisateur. Les deux faces d'une carte d'identité font deux appels ; donnez-leur la même référence.
  • Ne journalisez jamais le corps de la requête. C'est une pièce d'identité. Journalisez code, request_id et docs_url ; c'est tout ce dont le support a besoin.

Conserver moins de données

L'image envoyée est gardée en mémoire le temps de la requête et n'est jamais écrite sur un stockage durable. Le résultat est conservé pour que vous puissiez le récupérer avec GET /v1/scans/{id}, pendant une durée que vous choisissez : pour chaque requête, options.retain_hours accepte de 0 à 8760, et 0 ne conserve rien du tout. Si vous n'avez besoin du JSON qu'une fois, envoyez retain_hours: 0 et acceptez le compromis sur la relecture décrit plus haut. Le traitement a lieu dans l'UE.

Passer en production

Renseignez DOC_CHEAP_API_KEY avec votre propre clé, et rien d'autre ne change : même endpoint, même structure de réponse, même client. Une clé enregistrée autorise 60 requêtes par minute ; un traitement par lots qui respecte Retry-After n'aura donc pas besoin de son propre limiteur de débit. Les crédits s'achètent en cryptomonnaie (BTC, ETH, TRX, ou USDT sur Ethereum ou Tron) à partir de $1 ; il n'y a pas de paiement par carte pour l'instant.

Si un élément de la réponse est malcommode à traiter depuis Python, écrivez à admin@doc.cheap.