DoConure

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.

CampoObbligatorioPredefinitoSignificato
datasì—Oggetto libero. La sua forma deve corrispondere ai segnaposti del template — vedi Scrivere un template.
settings.templateIdsì—Deve appartenere all'organizzazione della chiave che hai usato.
settings.templateVersionnola versione attiva più altaFissa un numero di versione preciso.
settings.pdfAno"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.compressionLevelno"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.attachmentsno[]Fino a 5 fileId di PDF conservati da aggiungere dopo il documento generato, in ordine. Vedi File, grafici e link di download.
settings.colorModeno"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.passwordnonessunaImposta 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.pdfUAnofalseChiede 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.filenameno"{templateId}_{unixTimestamp}.pdf"Nome del file di uscita. Solo lettere, cifre, spazi, ., _, -; se lo ometti viene aggiunto .pdf.
settings.metadatano[]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.enableno"off""off" | "on". Con "on" è obbligatorio settings.watermark.text (≤60 caratteri) — stampato in diagonale su ogni pagina.
settings.datalessnofalseLo 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.responseFormatno"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.queueIfBusynofalseQuando 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.localeno"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 succedeQuando
Ricevi la prima risposta, byte per byteStessa 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 questoIl 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 regolato 0; una chiamata isTest ha regolato 0 crediti di produzione (spende invece la riserva di prova giornaliera, che è un'altra cosa) e il blocco porta allora anche testRemaining, gli elementi rimasti oggi in quella riserva. L'used di 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 è fatto used, una riga per operazione e pagatore: unit, quantity, credits e paidBy (credit, o plan quando l'ha pagato un contatore del tuo piano, a zero crediti). Le righe sommano a used; una chiamata di prova non elenca nulla; una risposta di errore porta solo used e left.
  • left è il saldo del registro adesso, letto dopo che l'operazione è stata regolata — lo stesso numero che GET /api/v1/usage riporta come balance.credits, al netto del credito tenuto dalle richieste ancora in corso. È un istante, non una promessa: non calcolarlo mai come il tuo left precedente meno used, perché una chiamata concorrente, un acquisto o un rimborso possono averlo spostato.
  • Anche una risposta di errore porta il blocco, con used: 0 per 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. È null quando il chiamante non è stato identificato o l'errore era dal nostro lato.
  • Una ripetizione di una Idempotency-Key restituisce l'used originale e un left letto di nuovo.
  • Una risposta PDF non ha corpo, quindi gli stessi due numeri sono gli header X-Credits-Used e X-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.

ChiaveSignificato
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.inputLo 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.creditsQuanto è costata la chiamata e quanto ti resta — vedi I crediti in ogni risposta.
output.warningsLa 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.errornull 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.
settingsQuello 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:

MandiCosa succede
"queueIfBusy": true in settingsTempo reale se c'è posto (il PDF, come sempre). Altrimenti il documento va in coda: 202 Accepted.
L'header Prefer: respond-asyncDirettamente in coda, senza provare il tempo reale: 202 Accepted. Per il lavoro che nessuno sta aspettando.
Nessuno dei dueIl 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 è:

statusSignificato
queued, processingIn attesa, o in lavorazione. output.status è pending.
succeededFatto: documentId e downloadUrl (GET /api/v1/compose/documents/{documentId}), pageCount, sha256.
expiredNon fatto entro 15 minuti: non si addebita nulla, il credito tenuto per lui è tornato.
failedNon 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 con TEST_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'header X-Test-Remaining su una risposta PDF). GET /api/v1/usage lo legge in qualunque momento, in testItems, con il momento della ricarica in resetsAt.
  • 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, con FEATURE_NOT_AVAILABLE_IN_TEST_MODE (422); details.features li 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.

HeaderSignificato
X-Datalesstrue o false, su ogni generazione. Sempre presente, così un client distingue "questo è stato conservato" da "questo server è troppo vecchio per dirlo".
X-Warning-CodeOgni 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-LeftQuanto ha regolato questa chiamata e quanto resta — su ogni generazione, dataless o no. Vedi I crediti in ogni risposta.
X-Document-IdVuoto. Non c'è nulla da scaricare.
X-Generation-Job-IdSempre 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.