DoConure

Generating a document

POST /api/v1/compose/documents/generate

The body has two parts: data, which is the only thing the template reads, and settings, which is everything about which template and how the PDF comes out — including which template, which is why it lives there rather than at the root:

{
  "data": {
    "number": "2026-000147",
    "customer": { "name": "Acme Srl", "vat": "IT01234567890" },
    "lines": [
      { "description": "Consulting", "quantity": 3, "total": "1,500.00" }
    ]
  },
  "settings": {
    "templateId": "invoice-standard-8bc99b",
    "templateVersion": 3
  }
}

Breaking change, 2026-09-14. templateId and templateVersion used to sit at the request root next to data. There is no fallback for the old shape: a request in the old shape is refused as INVALID_REQUEST_BODY. Extra or misspelled keys anywhere in the body are refused the same way, rather than silently ignored.

FieldRequiredDefaultMeaning
datayes—Free-form object. Its shape must match the template's placeholders — see Writing a template.
settings.templateIdyes—Must belong to the tenant whose key you used.
settings.templateVersionnohighest active versionPin a specific version number.
settings.pdfAno"none""none" | "1b" | "2b" | "3b" — requests a conversion to PDF/A with Ghostscript's best-effort export. Converted, not verified: the result is never checked against a PDF/A validator, so we do not say it conforms. Every conversion carries the warning PDFA_NOT_VERIFIED (2121) and output.compose.pdfa reads {"requested": "2b", "converted": true, "verified": false, "validator": null}. If you need a file that is certified PDF/A, validate it yourself (for example with veraPDF) before relying on it.
settings.compressionLevelno"none""none" | "lossless" | "balanced" | "small". lossless rewrites structure and fonts and removes duplicated images without changing a pixel; balanced reduces images to 150 dpi (JPEG quality 80); small to 72 dpi (quality 45, screen quality). The file is never larger than without it. Not applied together with pdfA (the export does its own pass) or colorMode: "blackwhite" (pages are already 1-bit): the document is produced as if you had not asked and the warning COMPRESSION_SKIPPED (2120, details.reason pdfa, blackwhite or not_smaller) says so. Runs before any seal or password. Measured results are in Files, charts and download links.
settings.attachmentsno[]Up to 5 fileIds of stored PDFs to append after the generated document, in order. See Files, charts and download links.
settings.colorModeno"color""color" | "gray" | "blackwhite". gray keeps vector text and shrinks colour images. blackwhite rasterises every page to a true 1-bit image — a real win for scan-like content, but vector text becomes an image (can read fuzzier) and a short document can end up larger. Reach for gray first.
settings.passwordnononeSets a PDF open password. Cannot be combined with pdfA other than "none" — PDF/A forbids encryption outright; the two together are refused as INVALID_REQUEST_BODY.
settings.pdfUAnofalsePDF/UA (accessibility: tagged structure, reading order) requested. Best-effort, conformance not verified, DOCX/XLSX only — refused on an HTML template (WeasyPrint cannot produce tagged output) and refused together with pdfA other than "none" (the PDF/A pass strips the tag tree). No sub-levels, hence a plain true/false.
settings.filenameno"{templateId}_{unixTimestamp}.pdf"Output filename. Letters, digits, spaces, ., _, - only; .pdf is appended if you omit it.
settings.metadatano[]List of {"name", "value"}. title/author/subject/keywords/creator/producer map to the standard PDF Info-dictionary keys; anything else becomes a custom entry.
settings.watermark.enableno"off""off" | "on". settings.watermark.text is required (≤60 characters) when "on" — stamped diagonally across every page.
settings.datalessnofalseCompose it and keep nothing: no payload, no stored PDF, no record of what was in either. The call costs exactly what it costs and X-Document-Id comes back empty. See Composing without leaving anything behind below.
settings.responseFormatno"pdf""pdf" | "json". pdf hands back the bytes, which is what every example here does. json hands back the standard {settings, output} envelope with the document base64-encoded inside it — one round trip, one parser, and the only way to get the file and the outcome when dataless means there is nothing to fetch later. See Asking for JSON instead.
settings.queueIfBusynofalseWhen the platform is at capacity, queue the document instead of refusing it: the answer is 202 with a job to follow, not the PDF. Off by default, so the answer you already handle never changes. See When the platform is busy: the queue.
settings.localeno"it-IT"Only value accepted today. Present now so a second locale is additive, not a breaking change.
{
  "data": { "number": "2026-000147", "...": "..." },
  "settings": {
    "templateId": "invoice-standard-8bc99b",
    "pdfA": "2b",
    "colorMode": "gray",
    "filename": "invoice-2026-000147",
    "metadata": [{ "name": "title", "value": "Invoice 2026-000147" }],
    "watermark": { "enable": "on", "text": "COPY" }
  }
}

Response

On success you get 200 and the PDF bytes in the body, not a JSON envelope with a 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 …

Keep X-Document-Id: it is the only way to fetch that document again, via GET /api/v1/compose/documents/{documentId} with the same tenant's key. X-Template-Version tells you which version actually produced the file — worth storing next to an archived PDF, so it can be traced back later.

Retrying safely

Send an Idempotency-Key header. A second request carrying the same key gets the first request’s answer rather than composing a second document and charging for it.

POST /api/v1/compose/documents/generate
Authorization: Bearer lpdc_…
Idempotency-Key: order-4471

It is optional, and you should not treat it as such. A client that retries a request which timed out is not an edge case — and from our side the retry looks exactly like a second piece of work, because that is what it is. Use something from your own domain that identifies the thing being made: an order number, a record id. A fresh UUID per attempt defeats the whole purpose.

What happensWhen
You get the first answer, byte for byteSame key, same request. The response carries Idempotent-Replay: true so you can tell.
IDEMPOTENCY_IN_PROGRESS (1105, 409)The first request is still running. Retryable — wait for the Retry-After and send it again.
IDEMPOTENCY_KEY_REUSED (1104, 422)Same key, different request. Answering it with the other call’s result would look to you exactly like success, so it is refused.
IDEMPOTENT_RESULT_GONE (1106, 410)The key is known and the document was not kept — a dataless generation, or one past its retention date. details.jobId names the work: you were charged once, which is what you were retrying to find out.
Nothing at allThe first attempt failed. A failure is never recorded as a result, so your next attempt runs for real — which matters if you fixed the payload in between.

A key means one call. It is scoped to your organisation, to the endpoint and to the exact request body, so two customers may both choose 1 and neither sees the other. The body is compared after being normalised, so a client library that orders JSON keys differently on the retry is still sending the same request.

Keys are forgotten after 24 hours, which is far longer than any sensible retry loop.

The same header works on every POST in this API, not only the ones that charge: on sending an envelope — the billable moment in Signer Fly, where a retry charges you and emails every signer a second time — on cancelling or resending one, on uploading a template or a version, where a retry leaves you two templates with the same name, and on the flow, which has carried one since it was written.

Cancelling is the case people do not expect. A cancel is harmless twice — and the second answer is a refusal, because the envelope is no longer cancellable. If you never saw the first answer you would read that refusal as the cancel not having happened, which is exactly backwards. With the key you get the first answer instead.

Asking for JSON instead

Set "responseFormat": "json" in settings and the same call answers with the envelope every other endpoint in this API answers with, instead of raw bytes:

{
  "data": { "number": "2026-000147", "...": "..." },
  "settings": {
    "templateId": "invoice-standard-8bc99b",
    "responseFormat": "json"
  }
}
{
  "settings": {
    "templateId": "invoice-standard-8bc99b",
    "templateVersion": null,
    "dataless": false,
    "...": "everything that actually applied"
  },
  "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
  }
}

Credits in every answer

Every completed operation carries output.credits, so you never need a second call to learn the balance:

"credits": {
  "items": [{"unit": "document", "quantity": 1, "credits": 2, "paidBy": "credit"}],
  "used": 2,
  "left": 1247
}
  • used is what this operation settled — read from the ledger after the work delivered, never an estimate and never a reservation. A call that failed and was refunded settled 0; an isTest call settled 0 production credits (it spends the daily test pool instead, which is a separate thing) and the block then also carries testRemaining, the items left in that pool today. A flow's used is the document plus the signature (and the seal and the timestamp, paid when the envelope is sent), and grows by each SMS code as it leaves, so the read that finds the flow closed carries the definitive figure.
  • items is what used is made of, one line per operation and payer: unit, quantity, credits and paidBy (credit, or plan when a counter of your plan paid it, at zero credits). The lines add up to used; a test call lists nothing; an error answer carries only used and left.
  • left is the ledger's balance now, queried after the operation settled — the same number as GET /api/v1/usage reports as balance.credits, net of credit held by requests still running. It is a point in time, not a promise: never compute it as your previous left minus used, because a concurrent call, a purchase or a refund may have moved it.
  • An error answer carries the block too, with used: 0 for a failure that settled nothing — and the document's cost for a flow that composed and charged the document before the envelope failed. It is null when the caller was not identified or the failure was on our side.
  • A replay of an Idempotency-Key returns the original used and a freshly queried left.
  • A PDF answer has no body, so the same two numbers are the headers X-Credits-Used and X-Credits-Left.

The rule, once

The section is named after the URL you called. /api/v1/compose fills output.compose, /api/v1/sign fills output.sign, and /api/v1/flow fills output.flow — and compose and sign as well, because a flow drives both. The sections you did not call are null rather than absent, so the shape never changes and a typed client declares it once.

KeyMeaning
output.status"succeeded", "pending" or "failed". The one question worth asking without a list of terminal states to hardcode: a composition is always succeeded by the time you read it; a signature or a flow is pending until it closes.
output.inputThe SHA-256 of the data you sent and its size, over a canonical serialisation (sorted keys, no spare whitespace) so your JSON library's key order does not change it. We do not echo data back: it would roughly double every response, and dataless exists precisely so this service never repeats your payload. File the hash next to your own record instead.
output.creditsWhat the call cost and what you have left — see Credits in every answer.
output.warningsThe call succeeded having done less than it was asked. Stable codes from the outcomes table, never prose to match on. One array per call, whichever half of the suite had something to say.
output.errornull on success. On a failure it is the same object the response already carries at the top level — so the outcome is in the same place whatever happened, and nothing that reads the flat error body has to change.
settingsWhat actually applied, not what you sent. Your organisation's defaults and gates can settle a field differently — dataless in particular is an always: switched on for the organisation, it is on for a request that said false.

Template management is the exception, on purpose. /api/v1/compose/templates and friends answer with the template itself, not with an output. output is for a call that produces something — a document, a signature, a flow. When you create a template, the template is the outcome, and wrapping it would add a level for nothing.

documentBase64 is always filled in JSON mode, not only when there is no stored copy. One rule beats a condition to remember, and a dataless generation has no second way to hand the file over at all. It costs about a third more bytes than "pdf"; if you do not want the file in the body, use "pdf" and read the headers, or fetch it later from downloadUrl.

When the platform is busy: the queue

A document is made in real time whenever there is room. When the platform is at capacity, a plain call is refused at once with a code you can retry (SERVER_BUSY, TENANT_CONCURRENCY_LIMIT_REACHED, with Retry-After), exactly as it always was. If you would rather wait than retry, ask for the queue - real time, else the queue, else a refusal:

You sendWhat happens
"queueIfBusy": true in settingsReal time if there is room (the PDF, as usual). If not, the document is queued: 202 Accepted.
The header Prefer: respond-asyncQueued directly, without trying real time: 202 Accepted. For work nobody is waiting on.
NeitherToday's behaviour: the PDF, or a refusal to retry.

A 202 carries output.compose.jobId, status: "queued", its position, and a Location header. Read that address (GET /api/v1/compose/documents/queued/{jobId}) every couple of seconds (Retry-After says how long) until status is:

statusMeaning
queued, processingWaiting, or being made. output.status is pending.
succeededMade: documentId and downloadUrl (GET /api/v1/compose/documents/{documentId}), pageCount, sha256.
expiredNot made within 15 minutes: nothing is charged, the credit held for it is back.
failedNot made, with the error a real-time call would have had (a template that does not compile, for example). Nothing is charged.

The credit is held when the document is queued and charged once when it is made. A retry with the same Idempotency-Key gets the same job back, never a second document. The queue is fair between organisations: a few documents do not wait behind somebody's hundreds. When the queue is full too (100 documents per organisation), the answer is QUEUE_CAPACITY_EXCEEDED (1809, 503) with Retry-After: nothing was held.

A queued document cannot use password, filename, responseFormat: "json", qeSeal or qualifiedTimestamp (the same limits as a bulk batch): with Prefer: respond-async that is a 422 FEATURE_NOT_AVAILABLE_IN_BULK; with queueIfBusy you simply get the real-time refusal, as without it. Another organisation's jobId is DOCUMENT_NOT_FOUND.

Test calls without spending credit

Send "isTest": true in settings to try an integration against your real templates without paying for it. The token needs the isTest scope (the starter key has it; tick Sandbox calls on a new key), otherwise the call is refused with TOKEN_SCOPE_NOT_ALLOWED before anything happens.

  • It spends your organisation’s shared daily pool of test items (20 a day unless an operator set another number), never credit. One call is one item; a /flow call is one item for the document and the signature together. When the pool is empty the call is refused whole with TEST_QUOTA_EXCEEDED. The pool refills at 00:00 UTC every day (a fixed reset, so you can plan around it; the console shows that moment in your organisation’s own time), and every test answer tells you what is left: output.credits.testRemaining (and an X-Test-Remaining header on a PDF answer). GET /api/v1/usage reads it any time, in testItems, with the reset moment in resetsAt.
  • The PDF is unmistakable: a solid red band reading TEST — NOT VALID — NO LEGAL VALUE across the top of every page, the words TEST — NOT VALID tiled over the whole page in red, and it is kept for a few days instead of the usual year.
  • Test and production are counted apart everywhere: the console, the usage endpoint and the export show production first and test beside it, never one number mixing the two.
  • It never reaches a paid third party. A call that asks for a qualified seal, a qualified timestamp, an SMS one-time code or an AES/QES level together with isTest: true is refused before anything is reserved, with FEATURE_NOT_AVAILABLE_IN_TEST_MODE (422); details.features names each. Nothing is silently degraded.

Composing without leaving anything behind

Send "dataless": true in settings and the document is produced, handed back, and then forgotten:

{
  "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 …

The response is the only copy there will ever be. It cannot be fetched again and cannot be re-made — the data it was made from is gone. Write the bytes somewhere before you do anything else. The generation still appears in your Documents list, because that is a record of the call rather than of the file; it simply has nothing to download.

What is not kept: the data object, the PDF, the render's warnings (they quote your own values back), the text of an error, and the free text inside settings — a filename, a metadata value, a watermark. Those last three are stored as [not recorded], so the shape of the request survives without its content.

What is kept, because none of it is yours: which template and version, when, how long it took, how many pages, how many bytes, whether it succeeded, and which fields the template wanted and did not get — those are names from your template, never values from your payload. Enough to bill the call and answer a question about it.

It costs the same. The work was done; not keeping a copy is a privacy choice, not a discount.

HeaderMeaning
X-Datalesstrue or false, on every generation. Always present, so a client can tell "this was kept" from "this server is too old to say".
X-Warning-CodeEvery warning code this call raised, comma-separated — DOCUMENT_NOT_STORED here, and TEMPLATE_FIELDS_MISSING or RENDER_NOTES when the payload did not fit the template. The one channel a warning has on a response whose body is a PDF; responseFormat: "json" is how you get the messages and the field names that go with them.
X-Credits-Used, X-Credits-LeftWhat this call settled and what is left — on every generation, dataless or not. See Credits in every answer.
X-Document-IdEmpty. There is nothing to fetch.
X-Generation-Job-IdStill a real id: the row that bills the call and answers a support question about it.

Your organisation can decide it for you

A coordinator can switch Never store the data you send us on under Settings → Privacy. Then every call is dataless whatever it asks for, including one that sends "dataless": false — a JSON body does not get to overrule its own organisation's privacy decision. The other direction is always open: a single request may ask for dataless even when the organisation has not.

Signatures are the exception. settings.dataless: true on POST /api/v1/flow is refused with DATALESS_NOT_AVAILABLE, before anything is composed or charged: a document being signed has to be kept, and its evidence is kept for ten years. If the organisation has the setting on, a flow still runs — the payload is not recorded, the document is kept, and the answer says so with DOCUMENT_STORED_FOR_SIGNATURE.