Generare un documento
POST /api/v1/compose/documents/generate
Il corpo ha due parti: data, l'unica cosa che il template legge, e settings, tutto ciò che
riguarda quale template e come esce il PDF — compreso quale template, ed è per questo che sta lì e
non alla radice:
{
"data": {
"number": "2026-000147",
"customer": { "name": "Acme Srl", "vat": "IT01234567890" },
"lines": [
{ "description": "Consulenza", "quantity": 3, "total": "1.500,00" }
]
},
"settings": {
"templateId": "invoice-standard-8bc99b",
"templateVersion": 3
}
}
Modifica incompatibile, 2026-09-14. templateId e templateVersion stavano alla
radice della richiesta accanto a data. Per la vecchia forma non c'è ripiego: una richiesta nella vecchia
forma è rifiutata come INVALID_REQUEST_BODY. Le chiavi in più o scritte male in qualunque punto del corpo
sono rifiutate allo stesso modo, invece di essere ignorate in silenzio.
| Campo | Obbligatorio | Predefinito | Significato |
|---|---|---|---|
data | sì | — | Oggetto libero. La sua forma deve corrispondere ai segnaposti del template — vedi Scrivere un template. |
settings.templateId | sì | — | Deve appartenere all'organizzazione della chiave che hai usato. |
settings.templateVersion | no | la versione attiva più alta | Fissa un numero di versione preciso. |
settings.pdfA | no | "none" | "none" | "1b" | "2b" | "3b" — chiede una conversione in PDF/A con l'esportazione al meglio di Ghostscript. Convertito, non verificato: il risultato non viene mai controllato con un validatore PDF/A, quindi non diciamo che è conforme. Ogni conversione porta l'avviso PDFA_NOT_VERIFIED (2121) e output.compose.pdfa riporta {"requested": "2b", "converted": true, "verified": false, "validator": null}. Se ti serve un file PDF/A certificato, validalo tu (per esempio con veraPDF) prima di contarci. |
settings.compressionLevel | no | "none" | "none" | "lossless" | "balanced" | "small". lossless riscrive struttura e font e toglie le immagini duplicate senza cambiare un pixel; balanced riduce le immagini a 150 dpi (qualità JPEG 80); small a 72 dpi (qualità 45, qualità schermo). Il file non è mai più grande che senza. Non si applica insieme a pdfA (l'esportazione fa il suo passaggio) o a colorMode: "blackwhite" (le pagine sono già a 1 bit): il documento è prodotto come se non l'avessi chiesto e l'avviso COMPRESSION_SKIPPED (2120, details.reason pdfa, blackwhite o not_smaller) lo dice. Gira prima di ogni sigillo o password. I risultati misurati sono in File, grafici e link di download. |
settings.attachments | no | [] | Fino a 5 fileId di PDF conservati da aggiungere dopo il documento generato, in ordine. Vedi File, grafici e link di download. |
settings.colorMode | no | "color" | "color" | "gray" | "blackwhite". gray mantiene il testo vettoriale e riduce le immagini a colori. blackwhite trasforma ogni pagina in una vera immagine a 1 bit — un vero vantaggio per contenuti tipo scansione, ma il testo vettoriale diventa un'immagine (può leggersi meno nitido) e un documento corto può risultare più grande. Prova prima gray. |
settings.password | no | nessuna | Imposta una password di apertura del PDF. Non si combina con pdfA diverso da "none" — il PDF/A vieta del tutto la cifratura; i due insieme sono rifiutati come INVALID_REQUEST_BODY. |
settings.pdfUA | no | false | Chiede il PDF/UA (accessibilità: struttura con tag, ordine di lettura). Al meglio, conformità non verificata, solo DOCX/XLSX — rifiutato su un template HTML (WeasyPrint non produce output con tag) e rifiutato insieme a pdfA diverso da "none" (il passaggio PDF/A toglie l'albero dei tag). Nessun sottolivello, quindi un semplice true/false. |
settings.filename | no | "{templateId}_{unixTimestamp}.pdf" | Nome del file di uscita. Solo lettere, cifre, spazi, ., _, -; se lo ometti viene aggiunto .pdf. |
settings.metadata | no | [] | Elenco di {"name", "value"}. title/author/subject/keywords/creator/producer corrispondono alle chiavi standard del dizionario Info del PDF; tutto il resto diventa una voce personalizzata. |
settings.watermark.enable | no | "off" | "off" | "on". Con "on" è obbligatorio settings.watermark.text (≤60 caratteri) — stampato in diagonale su ogni pagina. |
settings.dataless | no | false | Lo componi e non si conserva nulla: niente payload, niente PDF conservato, nessuna traccia di cosa c'era nell'uno o nell'altro. La chiamata costa esattamente quello che costa e X-Document-Id torna vuoto. Vedi Comporre senza lasciare nulla più sotto. |
settings.responseFormat | no | "pdf" | "pdf" | "json". pdf restituisce i byte, come fanno tutti gli esempi qui. json restituisce la busta standard {settings, output} con il documento in base64 al suo interno — un solo giro, un solo parser, e l'unico modo di avere il file e l'esito quando dataless vuol dire che dopo non c'è nulla da scaricare. Vedi Chiedere JSON invece. |
settings.queueIfBusy | no | false | Quando la piattaforma è al limite, metti il documento in coda invece di rifiutarlo: la risposta è 202 con un job da seguire, non il PDF. Spento per impostazione predefinita, così la risposta che già gestisci non cambia mai. Vedi Quando la piattaforma è occupata: la coda. |
settings.locale | no | "it-IT" | Unico valore accettato oggi. C'è già perché una seconda localizzazione sia un'aggiunta, non una modifica incompatibile. |
{
"data": { "number": "2026-000147", "...": "..." },
"settings": {
"templateId": "invoice-standard-8bc99b",
"pdfA": "2b",
"colorMode": "gray",
"filename": "fattura-2026-000147",
"metadata": [{ "name": "title", "value": "Fattura 2026-000147" }],
"watermark": { "enable": "on", "text": "COPIA" }
}
}
Risposta
In caso di successo ricevi 200 e i byte del PDF nel corpo, non una busta JSON con un link:
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="invoice-standard-8bc99b_1760400000.pdf"
X-Document-Id: 782b35cb-13a5-4621-b7df-af8b78d081c3
X-Template-Id: invoice-standard-8bc99b
X-Template-Version: 3
%PDF-1.6 …
Conserva X-Document-Id: è l'unico modo di riscaricare quel documento, con GET
/api/v1/compose/documents/{documentId} e la chiave della stessa organizzazione. X-Template-Version
ti dice quale versione ha prodotto davvero il file — vale la pena salvarlo accanto a un PDF archiviato, così lo si
può ricostruire più avanti.
Ritentare senza rischi
Manda l'header Idempotency-Key. Una seconda richiesta con la stessa chiave riceve la risposta della
prima invece di comporre un secondo documento e addebitarlo.
POST /api/v1/compose/documents/generate
Authorization: Bearer lpdc_…
Idempotency-Key: order-4471
È facoltativa, e non dovresti trattarla come tale. Un client che ritenta una richiesta andata in timeout non è un caso limite — e dal nostro lato il nuovo tentativo sembra esattamente un secondo lavoro, perché lo è. Usa qualcosa del tuo dominio che identifichi la cosa prodotta: un numero d'ordine, l'id di un record. Un UUID nuovo a ogni tentativo vanifica tutto.
| Cosa succede | Quando |
|---|---|
| Ricevi la prima risposta, byte per byte | Stessa chiave, stessa richiesta. La risposta porta Idempotent-Replay: true così lo sai. |
IDEMPOTENCY_IN_PROGRESS (1105, 409) | La prima richiesta sta ancora girando. Da ritentare — aspetta il Retry-After e rimandala. |
IDEMPOTENCY_KEY_REUSED (1104, 422) | Stessa chiave, richiesta diversa. Risponderle con il risultato dell'altra chiamata ti sembrerebbe esattamente un successo, quindi è rifiutata. |
IDEMPOTENT_RESULT_GONE (1106, 410) | La chiave è nota e il documento non è stato conservato — una generazione dataless, o una oltre la data di conservazione. details.jobId indica il lavoro: ti è stato addebitato una volta, che è proprio quello che ritentavi per sapere. |
| Niente di tutto questo | Il primo tentativo è fallito. Un errore non viene mai registrato come risultato, quindi il tentativo successivo gira davvero — il che conta se nel frattempo hai corretto il payload. |
Una chiave vuol dire una chiamata. Vale per la tua organizzazione, per l'endpoint e per l'esatto
corpo della richiesta, così due clienti possono scegliere entrambi 1 e nessuno dei due vede l'altro. Il
corpo è confrontato dopo essere stato normalizzato, quindi una libreria che al nuovo tentativo ordina le chiavi JSON
in modo diverso sta mandando la stessa richiesta.
Le chiavi si dimenticano dopo 24 ore, molto più di qualunque ciclo di tentativi ragionevole.
Lo stesso header funziona su ogni POST di questa API, non solo su quelli
che addebitano: sull'invio di una busta — il momento che si paga in Signer
Fly, dove un nuovo tentativo ti addebita e manda di nuovo l'email a ogni firmatario — sull'annullarne o
reinviarne una, sul caricamento di un template o di una versione, dove un nuovo
tentativo ti lascia due template con lo stesso nome, e sul flusso, che la porta da quando è
stato scritto.
L'annullamento è il caso che non ci si aspetta. Annullare due volte non fa danni — e la seconda risposta è un rifiuto, perché la busta non è più annullabile. Se non hai mai visto la prima risposta leggeresti quel rifiuto come un annullamento non avvenuto, cioè esattamente il contrario. Con la chiave ricevi invece la prima risposta.
Chiedere JSON invece
Imposta "responseFormat": "json" in settings e la stessa chiamata risponde con la busta con
cui risponde ogni altro endpoint di questa API, invece che con i byte grezzi:
{
"data": { "number": "2026-000147", "...": "..." },
"settings": {
"templateId": "invoice-standard-8bc99b",
"responseFormat": "json"
}
}
{
"settings": {
"templateId": "invoice-standard-8bc99b",
"templateVersion": null,
"dataless": false,
"...": "tutto quello che si è applicato davvero"
},
"output": {
"status": "succeeded",
"input": { "sha256": "9f2c…", "bytes": 412 },
"compose": {
"documentId": "782b35cb-13a5-4621-b7df-af8b78d081c3",
"generationJobId": "6b0f…",
"templateId": "invoice-standard-8bc99b",
"templateVersion": 3,
"status": "succeeded",
"contentType": "application/pdf",
"pageCount": 2,
"sizeBytes": 48213,
"sha256": "1c3d…",
"downloadUrl": "/api/v1/compose/documents/782b35cb-…",
"documentBase64": "JVBERi0xLjYK…",
"dataless": false,
"durationMs": 214,
"generatedAt": "2026-09-20T09:14:02Z"
},
"sign": null,
"flow": null,
"credits": { "used": 5, "left": 1247 },
"warnings": [],
"error": null
}
}
I crediti in ogni risposta
Ogni operazione completata porta output.credits, così non ti serve mai una seconda chiamata per sapere il
saldo:
"credits": {
"items": [{"unit": "document", "quantity": 1, "credits": 2, "paidBy": "credit"}],
"used": 2,
"left": 1247
}
usedè quello che questa operazione ha regolato — letto dal registro dopo che il lavoro è stato consegnato, mai una stima e mai una prenotazione. Una chiamata fallita e rimborsata ha regolato0; una chiamataisTestha regolato0crediti di produzione (spende invece la riserva di prova giornaliera, che è un'altra cosa) e il blocco porta allora anchetestRemaining, gli elementi rimasti oggi in quella riserva. L'useddi un flusso è il documento più la firma (e il sigillo e la marca temporale, pagati all'invio della busta), e cresce con ogni codice SMS quando parte, così la lettura che trova il flusso chiuso riporta la cifra definitiva.itemsè ciò di cui è fattoused, una riga per operazione e pagatore:unit,quantity,creditsepaidBy(credit, oplanquando l'ha pagato un contatore del tuo piano, a zero crediti). Le righe sommano aused; una chiamata di prova non elenca nulla; una risposta di errore porta solousedeleft.leftè il saldo del registro adesso, letto dopo che l'operazione è stata regolata — lo stesso numero cheGET /api/v1/usageriporta comebalance.credits, al netto del credito tenuto dalle richieste ancora in corso. È un istante, non una promessa: non calcolarlo mai come il tuoleftprecedente menoused, perché una chiamata concorrente, un acquisto o un rimborso possono averlo spostato.- Anche una risposta di errore porta il blocco, con
used: 0per un errore che non ha regolato nulla — e il costo del documento per un flusso che ha composto e addebitato il documento prima che la busta fallisse. Ènullquando il chiamante non è stato identificato o l'errore era dal nostro lato. - Una ripetizione di una
Idempotency-Keyrestituisce l'usedoriginale e unleftletto di nuovo. - Una risposta PDF non ha corpo, quindi gli stessi due numeri sono gli header
X-Credits-UsedeX-Credits-Left.
La regola, una volta
La sezione prende il nome dell'URL che hai chiamato. /api/v1/compose riempie
output.compose, /api/v1/sign riempie output.sign, e /api/v1/flow riempie
output.flow — e anche compose e sign, perché un flusso guida entrambi. Le sezioni
che non hai chiamato sono null invece che assenti, così la forma non cambia mai e un client tipizzato la
dichiara una volta.
| Chiave | Significato |
|---|---|
output.status | "succeeded", "pending" o "failed". L'unica domanda che vale la pena fare senza un elenco di stati finali da scrivere nel codice: una composizione è sempre succeeded quando la leggi; una firma o un flusso è pending finché non si chiude. |
output.input | Lo SHA-256 del data che hai mandato e la sua dimensione, su una serializzazione canonica (chiavi ordinate, nessuno spazio superfluo), così l'ordine delle chiavi della tua libreria JSON non lo cambia. Non ti rimandiamo data: raddoppierebbe circa ogni risposta, e dataless esiste proprio perché questo servizio non ripeta mai il tuo payload. Archivia invece l'impronta accanto al tuo record. |
output.credits | Quanto è costata la chiamata e quanto ti resta — vedi I crediti in ogni risposta. |
output.warnings | La chiamata è riuscita facendo meno di quanto le era stato chiesto. Codici stabili dalla tabella degli esiti, mai frasi da confrontare. Un array per chiamata, qualunque metà della suite avesse qualcosa da dire. |
output.error | null in caso di successo. In caso di errore è lo stesso oggetto che la risposta porta già al primo livello — così l'esito è nello stesso posto qualunque cosa sia successa, e nulla di ciò che legge il corpo di errore piatto deve cambiare. |
settings | Quello che si è applicato davvero, non quello che hai mandato. Le impostazioni predefinite e i filtri della tua organizzazione possono decidere diversamente un campo — dataless in particolare è un sempre: attivo per l'organizzazione, è attivo anche per una richiesta che ha detto false. |
La gestione dei template è l'eccezione, di proposito. /api/v1/compose/templates e
simili rispondono con il template stesso, non con un output. output è per una chiamata che
produce qualcosa — un documento, una firma, un flusso. Quando crei un template, il template è
l'esito, e avvolgerlo aggiungerebbe un livello per niente.
documentBase64 è sempre valorizzato in modalità JSON, non solo quando non c'è una copia
conservata. Una regola batte una condizione da ricordare, e una generazione dataless non ha un secondo
modo di consegnare il file. Costa circa un terzo di byte in più di "pdf"; se non vuoi il file nel corpo,
usa "pdf" e leggi gli header, o scaricalo dopo da downloadUrl.
Quando la piattaforma è occupata: la coda
Un documento si fa in tempo reale ogni volta che c'è posto. Quando la piattaforma è al limite, una
chiamata normale viene rifiutata subito con un codice da ritentare (SERVER_BUSY,
TENANT_CONCURRENCY_LIMIT_REACHED, con Retry-After), esattamente come sempre. Se preferisci
aspettare invece di ritentare, chiedi la coda — tempo reale, altrimenti la coda, altrimenti un
rifiuto:
| Mandi | Cosa succede |
|---|---|
"queueIfBusy": true in settings | Tempo reale se c'è posto (il PDF, come
sempre). Altrimenti il documento va in coda: 202 Accepted. |
L'header Prefer: respond-async | Direttamente in coda, senza provare il tempo reale:
202 Accepted. Per il lavoro che nessuno sta aspettando. |
| Nessuno dei due | Il comportamento di oggi: il PDF, o un rifiuto da ritentare. |
Un 202 porta output.compose.jobId, status: "queued", la sua position e
un header Location. Leggi quell'indirizzo (GET /api/v1/compose/documents/queued/{jobId}) ogni
paio di secondi (Retry-After dice quanto) finché status è:
status | Significato |
|---|---|
queued, processing | In attesa, o in lavorazione. output.status è pending. |
succeeded | Fatto: documentId e downloadUrl (GET /api/v1/compose/documents/{documentId}), pageCount, sha256. |
expired | Non fatto entro 15 minuti: non si addebita nulla, il credito tenuto per lui è tornato. |
failed | Non fatto, con l'error che avrebbe avuto una chiamata in tempo reale (un template che non compila, per esempio). Non si addebita nulla. |
Il credito è tenuto quando il documento va in coda e addebitato una volta quando è fatto. Un nuovo
tentativo con la stessa Idempotency-Key riceve lo stesso job, mai un secondo documento. La coda è equa tra
organizzazioni: pochi documenti non aspettano dietro le centinaia di qualcun altro. Quando anche la coda è piena (100
documenti per organizzazione), la risposta è QUEUE_CAPACITY_EXCEEDED (1809, 503) con
Retry-After: non è stato tenuto nulla.
Un documento in coda non può usare password, filename, responseFormat: "json",
qeSeal o qualifiedTimestamp (gli stessi limiti di un batch bulk): con
Prefer: respond-async è un 422 FEATURE_NOT_AVAILABLE_IN_BULK; con queueIfBusy ricevi
semplicemente il rifiuto in tempo reale, come senza. Il jobId di un'altra organizzazione è
DOCUMENT_NOT_FOUND.
Chiamate di prova senza spendere credito
Manda "isTest": true in settings per provare un'integrazione con i tuoi template reali senza
pagarla. Il token deve avere l'ambito isTest (la chiave iniziale ce l'ha; spunta Chiamate di prova su
una chiave nuova), altrimenti la chiamata è rifiutata con TOKEN_SCOPE_NOT_ALLOWED prima che succeda
qualunque cosa.
- Spende la riserva giornaliera di elementi di prova condivisa della tua organizzazione (20 al giorno
se un operatore non ha impostato un altro numero), mai credito. Una chiamata è un elemento; una chiamata
/flowè un elemento per documento e firma insieme. Quando la riserva è vuota la chiamata è rifiutata per intero conTEST_QUOTA_EXCEEDED. La riserva si ricarica alle 00:00 UTC ogni giorno (un momento fisso, così puoi organizzarti; la console lo mostra nell'ora della tua organizzazione), e ogni risposta di prova ti dice quanto resta:output.credits.testRemaining(e l'headerX-Test-Remainingsu una risposta PDF).GET /api/v1/usagelo legge in qualunque momento, intestItems, con il momento della ricarica inresetsAt. - Il PDF è inconfondibile: una fascia rossa piena con TEST — NOT VALID — NO LEGAL VALUE in cima a ogni pagina, le parole TEST — NOT VALID ripetute in rosso su tutta la pagina, ed è conservato pochi giorni invece del solito anno.
- Prova e produzione sono contate a parte ovunque: la console, l'endpoint dei consumi e l'esportazione mostrano prima la produzione e la prova accanto, mai un numero che mescola le due.
- Non raggiunge mai un terzo a pagamento. Una chiamata che chiede un sigillo qualificato, una marca temporale
qualificata, un codice monouso via SMS o un livello AES/QES insieme a
isTest: trueè rifiutata prima di riservare qualunque cosa, conFEATURE_NOT_AVAILABLE_IN_TEST_MODE(422);details.featuresli nomina uno per uno. Niente viene declassato in silenzio.
Comporre senza lasciare nulla
Manda "dataless": true in settings e il documento viene prodotto, consegnato e poi
dimenticato:
{
"data": { "cliente": { "nome": "Mario Rossi", "codiceFiscale": "…" } },
"settings": { "templateId": "contract-6f2a11", "dataless": true }
}
HTTP/1.1 200 OK
Content-Type: application/pdf
X-Dataless: true
X-Warning-Code: DOCUMENT_NOT_STORED
X-Document-Id:
X-Generation-Job-Id: 6b0f…
%PDF-1.6 …
La risposta è l'unica copia che ci sarà mai. Non si può riscaricare e non si può rifare — i dati da cui è stata fatta non ci sono più. Scrivi i byte da qualche parte prima di fare qualunque altra cosa. La generazione compare comunque nel tuo elenco Documenti, perché quella è la traccia della chiamata e non del file; semplicemente non ha nulla da scaricare.
Cosa non si conserva: l'oggetto data, il PDF, gli avvisi della composizione (riportano i tuoi
valori), il testo di un errore, e il testo libero dentro settings — un filename, il
value di un metadato, una filigrana. Questi ultimi tre sono salvati come [not recorded], così la
forma della richiesta sopravvive senza il suo contenuto.
Cosa si conserva, perché niente di questo è tuo: quale template e versione, quando, quanto è durato, quante pagine, quanti byte, se è riuscito, e quali campi il template voleva e non ha ricevuto — sono nomi del tuo template, mai valori del tuo payload. Abbastanza per addebitare la chiamata e rispondere a una domanda su di essa.
Costa uguale. Il lavoro è stato fatto; non conservare una copia è una scelta di riservatezza, non uno sconto.
| Header | Significato |
|---|---|
X-Dataless | true o false, su ogni generazione. Sempre presente, così un client distingue "questo è stato conservato" da "questo server è troppo vecchio per dirlo". |
X-Warning-Code | Ogni codice di avviso sollevato da questa chiamata, separati da virgole — qui DOCUMENT_NOT_STORED, e TEMPLATE_FIELDS_MISSING o RENDER_NOTES quando il payload non corrispondeva al template. L'unico canale che ha un avviso su una risposta il cui corpo è un PDF; responseFormat: "json" è il modo per avere i messaggi e i nomi dei campi che li accompagnano. |
X-Credits-Used, X-Credits-Left | Quanto ha regolato questa chiamata e quanto resta — su ogni generazione, dataless o no. Vedi I crediti in ogni risposta. |
X-Document-Id | Vuoto. Non c'è nulla da scaricare. |
X-Generation-Job-Id | Sempre un id vero: la riga che addebita la chiamata e risponde a una domanda del supporto. |
Può deciderlo la tua organizzazione
Un coordinatore può attivare Non conservare mai i dati che ci invii in Impostazioni → Privacy. Allora
ogni chiamata è dataless qualunque cosa chieda, compresa una che manda "dataless": false — un corpo JSON
non può scavalcare la decisione di riservatezza della propria organizzazione. L'altra direzione è sempre aperta: una
singola richiesta può chiedere dataless anche quando l'organizzazione non l'ha fatto.
Le firme sono l'eccezione. settings.dataless: true su POST
/api/v1/flow è rifiutato con DATALESS_NOT_AVAILABLE, prima di comporre o addebitare: un documento
da firmare deve essere conservato, e le sue prove si conservano dieci anni. Se l'organizzazione ha attiva
l'impostazione, un flusso gira comunque — il payload non viene registrato, il documento si conserva, e la risposta
lo dice con DOCUMENT_STORED_FOR_SIGNATURE.