Développeur

Valider une facture avec curl

Un terminal, curl et jq suffisent pour contrôler une facture électronique avant de l'envoyer. Voici l'appel en une ligne, puis un script shell complet, prêt pour une intégration continue : envoi du fichier, lecture du verdict, et un code de sortie pour chaque cas.

Avant de commencer

  • Une clé API, créée sur votre page de clés API. Elle ne s'affiche qu'une fois : copiez-la dès sa création.
  • curl, présent sur la plupart des systèmes, et jq, qui lit le JSON de la réponse. Sur Debian ou Ubuntu : apt install curl jq ; sur Alpine : apk add curl jq ; sur macOS : brew install jq.
  • Une facture : un PDF Factur-X, ou un XML CII ou UBL.

Rangez la clé dans une variable d'environnement, jamais dans le script lui-même ni dans l'historique de votre shell. Dans bash ou zsh, la commande read -s la lit sans l'afficher :

read -rs VALIDE_FACTURE_API_KEY && export VALIDE_FACTURE_API_KEY

Dans une intégration continue, déclarez-la comme variable masquée du projet (GitLab) ou comme secret du dépôt (GitHub) : elle arrive dans l'environnement du job sous ce même nom.

L'appel en une ligne

Pour voir la réponse sans rien écrire d'autre, depuis le répertoire où se trouve facture.pdf :

curl -sS -H "Authorization: Bearer $VALIDE_FACTURE_API_KEY" -F "file=@facture.pdf" https://valide-facture.fr/api/validate | jq .verdict

Elle affiche "CONFORME", "NON_CONFORME" ou "NON_EVALUABLE". Retirez .verdict pour lire le rapport entier. Sur un refus, elle affiche null : la réponse n'a pas de membre verdict, c'est un corps d'erreur. Le script ci-dessous fait la différence.

Le script complet

Enregistrez-le sous le nom valider.sh. Il n'utilise que le shell POSIX, il tourne donc aussi bien sous sh, dash, bash ou dans une image Alpine.

#!/bin/sh
# Valide une facture : sh valider.sh facture.pdf
# La clé API est lue dans la variable d'environnement VALIDE_FACTURE_API_KEY.
# Sortie : 0 conforme, 1 autre verdict, 3 refus ou erreur technique.
set -u

[ "$#" -eq 1 ] || { echo "Usage : VALIDE_FACTURE_API_KEY=... sh valider.sh <facture>" >&2; exit 3; }
command -v jq >/dev/null 2>&1 || { echo "jq est introuvable : installez-le." >&2; exit 3; }
fichier=$1
[ -n "${VALIDE_FACTURE_API_KEY:-}" ] || { echo "VALIDE_FACTURE_API_KEY est vide." >&2; exit 3; }
[ -r "$fichier" ] || { echo "Fichier illisible : $fichier" >&2; exit 3; }

corps=$(mktemp) && entetes=$(mktemp) || exit 3
trap 'rm -f "$corps" "$entetes"' EXIT

statut=$(curl --silent --show-error --max-time 35 \
    --header "Authorization: Bearer $VALIDE_FACTURE_API_KEY" \
    --form "file=@$fichier" \
    --dump-header "$entetes" --output "$corps" --write-out '%{http_code}' \
    https://valide-facture.fr/api/validate) || {
    echo "Pas de réponse du service : réessayez plus tard." >&2
    exit 3
}

if [ "$statut" = 200 ]; then
    verdict=$(jq -r '.verdict' "$corps")
    echo "Verdict : $verdict"
    jq -r '.findings[] | "- [\(.severity)] \(.code) : \(.messageFr)"' "$corps"
    [ "$verdict" = CONFORME ] && exit 0
    exit 1
fi

# Un refus du service est en application/problem+json. Un refus en amont (proxy, limite
# de débit du frontal) peut arriver en HTML : on ne lit pas son corps.
if ! grep -qi '^content-type: *application/problem+json' "$entetes"; then
    echo "Refus HTTP $statut, sans détail lisible : réessayez dans quelques instants." >&2
    exit 3
fi

type=$(jq -r '.type' "$corps")
echo "Refus HTTP $statut : $(jq -r '.detail' "$corps")" >&2
case "$type" in
    https://valide-facture.fr/problems/rate-limit-exceeded)
        attente=$(grep -i '^retry-after:' "$entetes" | tr -dc '0-9')
        echo "Trop de requêtes : réessayez dans $attente seconde(s)." >&2
        ;;
    https://valide-facture.fr/problems/quota-exceeded)
        echo "Quota de la période atteint : réessayer ne servira à rien avant la période suivante." >&2
        ;;
    *)
        echo "Type : $type" >&2
        ;;
esac
exit 3

Lancez-le sur une facture :

sh valider.sh facture.pdf

L'envoi du fichier : -F, et rien d'autre

L'API attend un envoi multipart/form-data, avec le fichier dans le champ file. C'est exactement ce que produit --form "file=@facture.pdf" : le @ dit à curl de joindre le contenu du fichier, et curl pose lui-même l'en-tête Content-Type, avec la frontière qui sépare les parties de l'envoi.

-d et --data-binary ne conviennent pas : ils envoient le fichier brut, sans champ, et l'API répond par un 400. Un autre nom de champ que file donne le même refus. Le type du document est reconnu sur ses octets : inutile d'ajouter ;type=application/pdf derrière le nom du fichier.

Le code HTTP, l'en-tête et le corps, lus séparément

Le script demande à curl trois choses distinctes : le code HTTP sur la sortie standard (--write-out '%{http_code}'), les en-têtes dans un fichier (--dump-header) et le corps dans un autre (--output). Chaque branche lit ce dont elle a besoin.

  • Un 200 porte toujours un rapport, y compris pour une facture non conforme : elle a été jugée. Le verdict se lit dans le membre verdict, pas dans le code HTTP.
  • Tout autre code est un refus : le service n'a pas pu juger. Le script vérifie d'abord l'en-tête Content-Type, puis branche sur le membre type du corps, jamais sur le seul code : deux refus de sens opposés partagent le 429.

N'utilisez pas --fail : sur un code d'erreur, curl jette alors le corps de la réponse, c'est-à-dire précisément le type et le detail qui disent quoi faire. --max-time 35 borne l'appel entier, envoi compris, à la durée que recommande la documentation de l'API ; au-delà, curl sort en erreur et le script rend 3.

Les deux 429, et le troisième

  • rate-limit-exceeded : la clé envoie trop de requêtes rapprochées. L'en-tête Retry-After donne le nombre de secondes à attendre. Le script l'affiche et s'arrête ; c'est à votre chaîne de décider de relancer.
  • quota-exceeded : les validations de la période sont consommées. Il n'y a pas de Retry-After, et relancer ne sert à rien : changez d'abonnement ou attendez la période suivante.
  • Un 429 sans application/problem+json : c'est la limite de débit du serveur frontal, qui répond par une page HTML. jq échouerait sur ce corps ; le script s'arrête avant de le lire.

Si vous relancez dans une boucle, bornez le nombre d'essais et attendez au moins la valeur de Retry-After. Une boucle sans borne sur un refus de quota ne fait que remplir vos journaux.

Dans une intégration continue

Les codes de sortie sont faits pour une étape de pipeline : 0 quand la facture est CONFORME, 1 pour tout autre verdict, 3 pour un refus ou une erreur technique. Le job échoue donc sur une facture non conforme, et vous distinguez dans le journal un document à corriger d'un service à rappeler plus tard. Le jeu de factures d'essai se contrôle fichier par fichier, avec une boucle for qui garde le pire code.

Chaque fichier validé consomme une unité de quota, à chaque exécution du pipeline. Un refus d'entrée (fichier vide, trop volumineux, ni PDF ni XML) et une indisponibilité du service n'en consomment pas. Gardez un petit jeu de factures représentatives plutôt que de contrôler tout un historique à chaque commit.

Les erreurs fréquentes avec curl

  • Le @ oublié. -F "file=facture.pdf" envoie le texte « facture.pdf » dans un champ ordinaire, au lieu du fichier : l'API répond par un 400, faute d'avoir reçu un fichier.
  • Un chemin avec des espaces. Gardez les guillemets autour de "file=@$fichier" : sans eux, le shell coupe le chemin en plusieurs arguments. Un nom de fichier qui contient une virgule ou un point-virgule est mal lu par -F : renommez-le ou copiez-le sous un nom simple.
  • Un Content-Type forcé. -F construit lui-même l'en-tête multipart, frontière comprise. Un -H "Content-Type: application/pdf" le remplace : le service ne retrouve plus le fichier dans l'envoi, et répond par un 400.
  • Une clé hors de l'environnement. Si VALIDE_FACTURE_API_KEY n'est pas exportée, curl envoie Bearer suivi de rien, et la réponse est un 401. Dans un script lancé par sh, une variable définie sans export n'arrive pas.
  • Une sortie jq non brute. Sans -r, jq rend "CONFORME" avec ses guillemets, et la comparaison [ "$verdict" = CONFORME ] échoue toujours.

Aller plus loin

La liste complète des refus et ce qu'il faut faire pour chacun est sur la page des guides du développeur. Chaque champ du rapport est décrit dans la documentation de l'API, et chaque code de règle a sa page parmi les codes d'erreur du validateur. Pour voir un rapport sans rien installer, déposez une facture dans le validateur.

Portée de cette page

Ce script est un exemple à adapter, pas un outil maintenu : il envoie une facture et lit la réponse, sans rien relancer de lui-même. Le verdict porte sur la conformité technique du document au format et aux règles appliquées par le validateur ; il ne présume pas de l'acceptation de la facture par la plateforme de votre destinataire.

Développeur