Aller au contenu
getnextpdf.com

stabilité: Bêta

Générer une facture de bout en bout via REST

Mène une facture depuis un JSON jusqu’à un PDF vérifié localement, à travers la surface Representational State Transfer (REST) de NextPDF Connect, un échange réseau à la fois. Cette recette soumet un job de rendu à POST /api/v1/jobs, rejoue la soumission sous la même Idempotency-Key pour montrer le comportement sûr en cas de doublon, interroge GET /api/v1/jobs/{id}, télécharge le PDF depuis GET /api/v1/jobs/{id}/result, vérifie les octets avec qpdf --check et supprime le job terminé.

Chaque réponse ci-dessous est une capture textuelle issue d’un véritable déploiement Connect de niveau core (nextpdf/server sous RoadRunner, lié à http://localhost:8080). La seule substitution est la clé d’API, présentée sous forme de variable d’environnement $NEXTPDF_CONNECT_TOKEN ; les identifiants de job, les identifiants de requête, les horodatages, les en-têtes et les octets du corps sont exactement ce que le serveur a renvoyé. Des valeurs d’en-tête telles que Date et X-Request-Id différeront bien sûr sur ton déploiement.

Cette recette pilote un seul document afin que tu puisses lire chaque échange dans son intégralité. Pour de nombreux documents, une concurrence bornée et des boucles de sondage guidées par Retry-After, consulte Générer des PDF par lots avec suivi de progression, qui utilise la même surface de jobs.

Côté serveur, il s’agit de la distribution Connect standard :

Fenêtre de terminal
composer require nextpdf/server

Le côté client de cette recette repose sur curl et qpdf, de sorte que tu peux le porter vers n’importe quel client HTTP. Exporte d’abord les valeurs de ton déploiement :

/docs/connect/quickstart/
export NEXTPDF_CONNECT_URL="http://localhost:8080"
export NEXTPDF_CONNECT_TOKEN="npk_live_{kid}_{secret}" # your real key
# Key provisioning and server startup live in the quickstart:

La surface des jobs asynchrones sépare la soumission de la récupération : tu soumets une requête de rendu, tu reçois un enregistrement de job et tu récupères le résultat lorsque le job atteint completed. La requête de rendu elle-même est un tableau operations ordonné — les mêmes types d’opérations (set_font, add_text, add_table, add_image, add_page) qui sous-tendent les appels d’outils Connect sur chaque transport — auxquels s’ajoutent des champs de niveau document (page_size, orientation, title, author).

Deux détails du contrat façonnent la transcription que tu t’apprêtes à lire :

  • Soumission idempotente. Une soumission identifiée par une Idempotency-Key renvoie 201 Created la première fois et 200 OK avec le même enregistrement de job lorsqu’elle est rejouée, de sorte qu’un réessai réseau ne déclenche jamais deux rendus.
  • La soumission peut déjà être terminale. La version actuelle traite le job de façon synchrone avant de répondre au POST, de sorte que la réponse de soumission peut déjà porter status: "completed" — comme c’est le cas ci-dessous. Le contrat « interroger jusqu’à l’état terminal » est la forme stable de l’API : écris la boucle de sondage et accepte un état terminal à n’importe quelle tentative, y compris la première.

Tu peux confirmer ce que ton déploiement expose avant de soumettre quoi que ce soit : GET /api/v1/capabilities renvoie le catalogue d’opérations que le niveau de ta clé d’API peut atteindre. Sur le déploiement de niveau core capturé ici, il ne listait que les opérations core ; le catalogue de référence est toujours la réponse du serveur en cours d’exécution, pas cette page.

ÉchangeMéthode et cheminStatut capturé
Soumettre le job de renduPOST /api/v1/jobs201 Created
Rejouer la même soumissionPOST /api/v1/jobs (même Idempotency-Key)200 OK
Interroger l’enregistrement du jobGET /api/v1/jobs/{id}200 OK
Télécharger le PDFGET /api/v1/jobs/{id}/result200 OK, application/pdf
Supprimer le job terminéDELETE /api/v1/jobs/{id}204 No Content

L’authentification repose sur un jeton porteur pour chaque requête /api/v1/* : Authorization: Bearer npk_live_{kid}_{secret}. Les réponses JSON réussies partagent l’enveloppe { "data": ..., "meta": ... } ; les champs sur lesquels tu agis se trouvent sous data.

Écris la requête de rendu dans invoice.json. Il s’agit d’une liste d’opérations simple et déterministe — une ligne d’en-tête en gras, une ligne d’émission et un tableau de postes :

{
"page_size": "A4",
"orientation": "portrait",
"title": "Invoice INV-2026-0042",
"author": "Aurora Fixtures Ltd.",
"operations": [
{ "type": "set_font", "family": "helvetica", "style": "B", "size": 16 },
{ "type": "add_text", "text": "Invoice INV-2026-0042" },
{ "type": "set_font", "family": "helvetica", "style": "", "size": 10 },
{ "type": "add_text", "text": "Issued 2026-07-08 by Aurora Fixtures Ltd. Payment is due within 30 days.", "width": 0, "line_height": 5 },
{ "type": "add_table", "html": "<table><tr><th>Item</th><th>Qty</th><th>Unit price</th><th>Amount</th></tr><tr><td>Cable tray, 300 mm</td><td>12</td><td>18.40</td><td>220.80</td></tr><tr><td>Mounting kit</td><td>4</td><td>9.75</td><td>39.00</td></tr><tr><td>Site delivery</td><td>1</td><td>25.00</td><td>25.00</td></tr><tr><td>Total (EUR)</td><td></td><td></td><td>284.80</td></tr></table>" }
]
}

Les champs de facture présentés ici sont des données d’exemple. La forme d’arguments faisant autorité pour chaque opération est celle que rapporte ton déploiement — via MCP, tools/list renvoie le schéma d’entrée complet pour chaque type d’opération que cette requête utilise.

Fenêtre de terminal
curl -sS -i -X POST "$NEXTPDF_CONNECT_URL/api/v1/jobs" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: inv-2026-0042" \
--data-binary @invoice.json

Le serveur répond 201 Created :

HTTP/1.1 201 Created
Cache-Control: no-store
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
Content-Type: application/json; charset=utf-8
Referrer-Policy: no-referrer
Vary: Accept-Encoding
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Powered-By: NextPDF Connect
X-Ratelimit-Remaining: 99
X-Request-Id: 019f3fd6-c6ed-727c-958e-2fa4370ba97e
Date: Wed, 08 Jul 2026 03:47:48 GMT
Transfer-Encoding: chunked
{
"data": {
"job_id": "job_9bd0808960f10eb568484acc",
"status": "completed",
"created_at": "2026-07-08T03:47:48+00:00",
"started_at": "2026-07-08T03:47:48+00:00",
"completed_at": "2026-07-08T03:47:48+00:00",
"result_url": "/api/v1/jobs/job_9bd0808960f10eb568484acc/result"
},
"meta": {
"request_id": "019f3fd6-c6ed-727c-958e-2fa4370ba97e",
"timestamp": "2026-07-08T03:47:48+00:00",
"duration_ms": 63.31,
"api_version": "v1"
}
}

Le job est déjà terminal dans cette capture — status vaut "completed" et result_url est présent — parce que la version actuelle effectue le rendu de façon synchrone avant de répondre. Ne t’appuie pas sur ce comportement : traite la réponse de soumission comme le premier résultat de sondage et base-toi sur data.status comme pour n’importe quel autre sondage.

Réexécute exactement la même commande — même Idempotency-Key, même corps :

Fenêtre de terminal
curl -sS -i -X POST "$NEXTPDF_CONNECT_URL/api/v1/jobs" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: inv-2026-0042" \
--data-binary @invoice.json

Le serveur renvoie 200 OK — et non 201 — avec le même job_id, et aucun second rendu ne se produit (compare meta.duration_ms avec la première réponse) :

HTTP/1.1 200 OK
Cache-Control: no-store
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
Content-Type: application/json; charset=utf-8
Referrer-Policy: no-referrer
Vary: Accept-Encoding
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Powered-By: NextPDF Connect
X-Ratelimit-Remaining: 99
X-Request-Id: 019f3fd6-c756-7226-bc85-b255d75bd449
Date: Wed, 08 Jul 2026 03:47:48 GMT
Transfer-Encoding: chunked
{
"data": {
"job_id": "job_9bd0808960f10eb568484acc",
"status": "completed",
"created_at": "2026-07-08T03:47:48+00:00",
"started_at": "2026-07-08T03:47:48+00:00",
"completed_at": "2026-07-08T03:47:48+00:00",
"result_url": "/api/v1/jobs/job_9bd0808960f10eb568484acc/result"
},
"meta": {
"request_id": "019f3fd6-c756-7226-bc85-b255d75bd449",
"timestamp": "2026-07-08T03:47:48+00:00",
"duration_ms": 1.03,
"api_version": "v1"
}
}
Fenêtre de terminal
curl -sS -i "$NEXTPDF_CONNECT_URL/api/v1/jobs/job_9bd0808960f10eb568484acc" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN"
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
Content-Type: application/json; charset=utf-8
Referrer-Policy: no-referrer
Vary: Accept-Encoding
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Powered-By: NextPDF Connect
X-Ratelimit-Remaining: 98
X-Request-Id: 019f3fd7-0038-7131-b71e-4c69e637233b
Date: Wed, 08 Jul 2026 03:48:02 GMT
Transfer-Encoding: chunked
{
"data": {
"job_id": "job_9bd0808960f10eb568484acc",
"status": "completed",
"created_at": "2026-07-08T03:47:48+00:00",
"started_at": "2026-07-08T03:47:48+00:00",
"completed_at": "2026-07-08T03:47:48+00:00",
"result_url": "/api/v1/jobs/job_9bd0808960f10eb568484acc/result"
},
"meta": {
"request_id": "019f3fd7-0038-7131-b71e-4c69e637233b",
"timestamp": "2026-07-08T03:48:02+00:00",
"duration_ms": 0.24,
"api_version": "v1"
}
}

Ce sondage montre un enregistrement terminal : il n’y a donc pas d’en-tête Retry-After ni de champ poll_url. Tant qu’un job est encore pending ou running, le serveur définit Retry-After (un intervalle de 2 secondes) à chaque sondage — respecte-le au lieu d’interroger dans une boucle serrée.

Fenêtre de terminal
curl -sS -D result-headers.txt \
"$NEXTPDF_CONNECT_URL/api/v1/jobs/job_9bd0808960f10eb568484acc/result" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN" \
-o invoice-inv-2026-0042.pdf
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Disposition: attachment; filename="job-job_9bd0808960f10eb568484acc.pdf"
Content-Length: 3663
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
Content-Type: application/pdf
Referrer-Policy: no-referrer
Vary: Accept-Encoding
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Powered-By: NextPDF Connect
X-Ratelimit-Remaining: 98
X-Request-Id: 019f3fd7-0056-711e-acad-9f7ac5f21ba7
Date: Wed, 08 Jul 2026 03:48:02 GMT

Le corps est le binaire du PDF — 3 663 octets dans cette capture, ce qui correspond à l’en-tête Content-Length — et il est omis ici. Il est écrit dans invoice-inv-2026-0042.pdf.

Un 200 avec Content-Type: application/pdf ne prouve pas, à lui seul, que le corps est un PDF bien formé. Effectue une vérification structurelle avec qpdf :

Fenêtre de terminal
qpdf --check invoice-inv-2026-0042.pdf

Sortie capturée pour le fichier téléchargé ci-dessus :

checking invoice-inv-2026-0042.pdf
PDF Version: 2.0
File is not encrypted
File is not linearized
No syntax or stream encoding errors found; the file may still contain
errors that qpdf cannot detect

La formulation de qpdf pose elle-même la limite en toute honnêteté : il s’agit d’une vérification de syntaxe et de flux, pas d’un constat de conformité à une quelconque norme.

Fenêtre de terminal
curl -sS -i -X DELETE "$NEXTPDF_CONNECT_URL/api/v1/jobs/job_9bd0808960f10eb568484acc" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN"
HTTP/1.1 204 No Content
Cache-Control: no-store
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
Content-Type: application/json; charset=utf-8
Referrer-Policy: no-referrer
Vary: Accept-Encoding
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Powered-By: NextPDF Connect
X-Ratelimit-Remaining: 97
X-Request-Id: 019f3fd7-0084-722f-bf54-3dabead8aeea
Date: Wed, 08 Jul 2026 03:48:02 GMT

Après la suppression, l’enregistrement du job et son résultat stocké ont disparu ; un GET ultérieur sur le job renvoie 404.

  • Base-toi sur data.status, pas sur le seul statut HTTP. La soumission, le rejeu et le sondage renvoient tous un 2xx avec un enregistrement de job ; l’état du cycle de vie se trouve dans data.status (pending, running, completed, failed, cancelled).
  • Une clé rejouée avec un corps différent donne un 409 Conflict. Le rejeu idempotent 200 ne se produit que lorsque le corps correspond à la soumission d’origine. Ne réutilise jamais une clé pour un contenu différent.
  • Appeler /result avant l’achèvement donne un 409. Ne télécharge qu’une fois qu’un sondage indique completed. Le 409 est une réponse normale à inspecter, pas une défaillance du transport — c’est la même séparation entre transport et statut que suit chaque recette Connect (voir Conventions des recettes Connect).
  • Les jobs sont limités à leur propriétaire. Un job soumis avec une clé d’API est invisible pour une autre clé : un GET inter-propriétaires renvoie 404, et non 403. Interroge avec l’identité que tu as utilisée pour soumettre.
  • progress peut être absent. L’enregistrement capturé ne porte aucun champ progress parce que le job était déjà terminal. Lorsque le serveur suit la progression d’un job non terminal, data.progress est un entier de 0 à 100 ; traite un champ manquant comme inconnu, pas comme zéro.
  • Un job failed porte un data.error. Consigne-le ; ne resoumets pas à l’aveugle.

Un job de rendu coûte une soumission, tout au plus une poignée de sondages et un téléchargement. Les valeurs meta.duration_ms capturées parlent d’elles-mêmes : 63,31 ms pour effectuer le rendu de la facture lors de la soumission, 1,03 ms pour le rejeu idempotent qui n’a effectué aucun travail, et des lectures de statut inférieures à la milliseconde. Interroge à la cadence Retry-After du serveur plutôt que dans une boucle serrée ; la lecture de statut est peu coûteuse mais pas gratuite, et le limiteur de débit la comptabilise (observe X-Ratelimit-Remaining décroître dans les en-têtes capturés). Pour les lots, borne le nombre de jobs en vol au lieu de tout soumettre d’un coup — la recette de lots implémente cette boucle.

  • Conserve le jeton porteur uniquement dans l’en-tête Authorization. Jamais dans une chaîne de requête, une ligne de journal ou un fichier versionné. La transcription ci-dessus substitue une variable d’environnement précisément pour cette raison.
  • Valide les octets téléchargés avant de leur faire confiance. L’étape 5 fait partie du flux, ce n’est pas une étape facultative : vérifie que la réponse est bien un PDF (au minimum l’en-tête %PDF, qpdf --check pour la structure) avant de l’archiver ou de le transmettre.
  • Supprime les jobs terminés dont tu n’as plus besoin. L’étape 6 retire le résultat stocké du serveur ; sinon, un job terminé reste téléchargeable jusqu’à ce que le ramasse-miettes de jobs du serveur le supprime.
  • Utilise une clé au moindre privilège. Ce flux nécessite une clé de rendu de niveau core, et rien de plus.

Cette recette ne formule aucune revendication normative. Il sollicite les points de terminaison REST des jobs asynchrones de Connect et lit les champs d’enregistrement de job que le serveur définit. L’étape qpdf --check confirme uniquement l’intégrité structurelle — « the file may still contain errors that qpdf cannot detect » est la propre mise en garde de qpdf, citée textuellement ci-dessus. Déterminer la conformité à une norme (PDF/A-4, PDF/UA) relève d’un validateur indépendant, et d’une autre surface — voir Lancer un contrôle de conformité pour cette frontière.