# DoConure — full contract for machine readers DoConure turns structured data into finished documents and sends them for electronic signature, over a plain JSON HTTP API. You upload a Word, Excel or HTML template with placeholders in it, then POST your data; a PDF comes back. There is no SDK to install and no OAuth flow: authentication is a Bearer key per organisation, scoped and expiring. This file is generated from the running service: the settings come from the request model, the outcome numbers from the error catalogue, the response shape from the code that builds it. If it disagrees with the API, the API is what changed. ## Base URL and authentication - Base URL: `https://app.doconure.com` - Every call: `Authorization: Bearer ` - Named keys per organisation, issued from the console, no OAuth flow. Every key **expires** (30, 90, 180, 365 or 730 days; no grace period) and the coordinators are emailed at 30, 7 and 1 day before. - A key carries scopes and a new account starts with a restricted **starter key** (`hello-world`: `documents:compose`, `documents:read`, `documents:download`, `usage:*`, `isTest`). A call it may not make answers `TOKEN_SCOPE_NOT_ALLOWED` (403) with `details.missingScopes`. Needed per call: `documents:compose` to generate, `documents:download` to fetch a document, `documents:templates` for templates, `signatures:ses` for envelopes, `flow:*` for the flow, `bulk:read` to read bulk jobs, and for a bulk submit **both** `bulk:submit` and the single call's scope (`documents:compose` for `compose`, `signatures:ses` for `sign`, `flow:*` for `flow`), `isTest` for `isTest: true`; a group scope (`documents:*`) covers its group. `GET /api/v1/usage` and `GET /api/v1/callback-environments` need none. - A request body is at most **25 MB**: a larger one is refused before it reaches the service with `PAYLOAD_TOO_LARGE` (1803, HTTP 413), in the usual JSON error body on `/api/*` and `/verify.json`. No limit is above it: a template is at most 25 MB (about 18 MB when sent as base64 in JSON), a bulk file 25 MB, and `GET /api/v1/usage` says the organisation's own. - Every time in an answer or a webhook is **UTC** (ISO 8601). The console, mails, certificate and exports write times in the organisation's locale. - `GET /api/v1/usage` returns `limits` with the same numbers as the console's Limits page, including `assetBytes`, `downloadLinks` (`defaultSeconds`, `maxSeconds`) and `storedFilesDays`. The lists `GET /api/v1/sign/envelopes` and `GET /api/v1/flow` take `?kind=production|test|all`. - A key can have its own requests-per-minute limit on top of the organisation's: over it, `RATE_LIMIT_EXCEEDED` (429) with `details.limitedBy: "token"` and the usual `Retry-After`. - Read the key from the environment (`DOCONURE_API_KEY` by convention). Never write it into a source file, a committed config or a log line. - A request with no key answers `MISSING_CREDENTIALS` (1001); with a bad one, `INVALID_API_KEY` (1002). ## Checking your data A template version may carry a `jsonSchema` (a bounded subset of draft 2020-12; anything else, and any remote `$ref`, is refused at upload with `JSON_SCHEMA_INVALID`). The template's `payloadValidation` is `warn` (default: the document is produced and `output.warnings` carries `PAYLOAD_SCHEMA_VIOLATIONS` with `details.violations`, `{path, keyword, message}`, never a value), `reject` (422 `PAYLOAD_SCHEMA_VIOLATION` before anything is charged) or `off`. Send `jsonSchema` and `payloadValidation` with a new template version to set both in one call. ## Validate before you send (no charge) `POST /api/v1/compose/documents/validate` takes the body of `generate` (plus optional `estimatePages`, default true, and `plannedExtras: ["signature"]`) and answers what `generate` would find, **without charging, reserving or storing anything**. Scope: `documents:compose`. `output.compose.validation` has `ok`, `errors` and `warnings` (catalogue-shaped), `templateErrors`/`templateWarnings` (plain sentences from the template diagnostics, charts included), `missingFields`, `unusedFields`, `payloadSchema`, `pages` (a render made for this call only: `estimate`, `method`), `files` and `estimatedCredits`. `estimatedCredits` is a deterministic model: `credits` is the document's rate, charged on delivery; `certainty` is `exact` or `dependsOn`, and `dependsOn` lists what may be charged later (a seal or timestamp if applied, a planned signature) with `maximum`; an `isTest` call is 0 credits and one test item. It takes an optional `Idempotency-Key` like every POST. There is no image preview: the page count is the one render the call may make. ## Charts, images by reference, attachments - **Charts**: in a Word or HTML template write `{{ sales:::chart:150 }}` (width in mm, 40 to 180, default 150) and send `sales` as `{"type": "bar"|"line"|"pie", "title"?, "labels": [...], "series": [{"name", "values": [...]}], "alt"?}`: at most 6 series, 60 points (12 slices, one series for a pie). Drawn on our side; no link is fetched; alt text is yours or built from the data. A spec that cannot be drawn is left out with a render note, in strict and lenient templates alike; `validate` reports it first. Excel templates cannot place a chart. - **Word checkboxes**: a boolean in `data` with the name of a checkbox (a legacy checkbox's bookmark, or a content control's Tag/Title) ticks it (`true`) or clears it (`false`); absent or `null` leaves it as the template has it; any other value is not guessed at and is noted in the warnings. - **Stored files**: `POST /api/v1/compose/files` (multipart `file`, JSON `{fileBase64, fileName}`, or raw bytes) stores a PNG, JPEG, WebP or PDF, decided from its bytes, encrypted, kept 30 days by default (an operator may set up to 365 for an organisation), and answers a `fileId`. Identical bytes are one file. `GET`/`DELETE` `/files/{fileId}`, `GET /files`. A PDF with JavaScript, launch actions, embedded files or a password is refused (`FILE_REJECTED`). Another organisation's id is `STORED_FILE_NOT_FOUND`. - **Image by reference**: put `{"$fileId": ""}` where the payload would carry a data-URI image (`:::img`, or an `img src`). - **PDF attachments**: `settings.attachments: ["", ...]` (up to 5) are appended after the generated document, before watermark, colour, compression, metadata and password; their annotations are dropped. - **`settings.compressionLevel`**: `none` (default), `lossless`, `balanced`, `small`. Never returns a larger file; not applied with `pdfA` or `colorMode: "blackwhite"` (warning `COMPRESSION_SKIPPED`). - **PDF/A is converted, not verified.** `settings.pdfA` runs a best-effort Ghostscript export that is not checked by any PDF/A validator. Every conversion carries the warning `PDFA_NOT_VERIFIED` and `output.compose.pdfa` is `{requested, converted: true, verified: false, validator: null}`. Never describe such a file as PDF/A compliant. ## Signed download links `POST /api/v1/compose/documents/{id}/download-links` (`{expiresInSeconds?, singleUse?}`; scope `documents:download`) answers `output.compose.downloadLink` with `url` and `token` **once**. Anybody holding the url can `GET /dl/` with no key and no cookie until it expires (default 1 hour, never more than the organisation's cap, 48 hours by default; more is refused) or is revoked: `DELETE .../download-links/{linkId}` or `DELETE .../download-links` (all of the document's). `singleUse` burns on the first request. The token is HMAC-signed over link, organisation, document and document SHA-256; the answer is `Cache-Control: no-store`. `404 DOWNLOAD_LINK_INVALID` covers unknown, altered, revoked, spent; `410 DOWNLOAD_LINK_EXPIRED` a genuine expired one. ## The three surfaces Which product a call belongs to is decided from its path prefix, and each can be switched off independently for an organisation. | Product | Prefix | What it does | |---|---|---| | Composer Fly | `/api/v1/compose/*` | Document composition. DOCX, XLSX or HTML template in, PDF out. | | Signer Fly | `/api/v1/sign/*` | Simple electronic signature (SES) of a document composed here or of a PDF you upload once and name by fileId (POST /api/v1/sign/files). The signer signs on a page hosted by DoConure at a secure public URL; embedded or iframe signing does not exist and is not promised. The qualified seal (2 credits, 6 with the qualified timestamp) is available to every non-test organisation: it says the file came from DoConure and is unaltered, not who signed, and what it cost comes back when it cannot be applied. AES and QES are coming soon: until then they are deliberately refused (SIGNATURE_LEVEL_UNAVAILABLE, before anything is charged) rather than approximated. | | Document Fly | `/api/v1/flow/*` | Both of the above in one call: compose the document and put it out for signature, with one charge decision and one response. | Older prefixes still answer and are governed identically: `/api/v1/documents`, `/api/v1/templates`, `/api/v1/signatures`. ## Composing a document `POST /api/v1/compose/documents/generate` Body is `{data, settings}`. `data` is the only thing the template reads, and is free-form; everything else is a setting. **Unknown keys anywhere outside `data` are refused**, not ignored, so a misspelled setting fails loudly. By default the response **is the PDF**, with the identifiers in headers: `X-Document-Id`, `X-Generation-Job-Id`, `X-Template-Id`, `X-Template-Version`, `X-Dataless`, and `X-Warning-Code` (comma-separated) when anything warned. ### settings | Field | Type | Required | Default | |---|---|---|---| | `templateId` | `string` | yes | — | | `templateVersion` | `integer` \| `null` | no | `null` | | `pdfA` | `none` \| `1b` \| `2b` \| `3b` | no | `none` | | `colorMode` | `color` \| `gray` \| `blackwhite` | no | `color` | | `compressionLevel` | `none` \| `lossless` \| `balanced` \| `small` | no | `none` | | `attachments` | array of `string` | no | — | | `password` | `string` \| `null` | no | `null` | | `pdfUA` | `boolean` | no | `false` | | `filename` | `string` \| `null` | no | `null` | | `metadata` | array of object (`MetadataTag`) | no | — | | `watermark` | object (`Watermark`) | no | — | | `locale` | `it-IT` | no | `it-IT` | | `dataless` | `boolean` | no | `false` | | `isTest` | `boolean` | no | `false` | | `responseFormat` | `pdf` \| `json` | no | `pdf` | | `queueIfBusy` | `boolean` | no | `false` | | `qeSeal` | `boolean` \| `null` | no | `null` | | `qualifiedTimestamp` | `boolean` \| `null` | no | `null` | Prose for each field: https://app.doconure.com/docs/generate ## The response envelope Send `"responseFormat": "json"` and every JSON answer in this API has one shape. **The section is named after the URL prefix you called**: `/api/v1/compose` fills `output.compose`, `/api/v1/sign` fills `output.sign`, `/api/v1/flow` fills `output.flow` and both of the others. Sections you did not call are `null`, never absent. ```json { "settings": { "...": "as settled" }, "output": { "status": "succeeded", "input": { "sha256": "", "bytes": 412 }, "compose": { "documentId": "", "generationJobId": "", "templateId": "", "templateVersion": 3, "status": "succeeded", "contentType": "application/pdf", "pageCount": 2, "sizeBytes": 48213, "sha256": "", "downloadUrl": "/api/v1/compose/documents/", "documentBase64": null, "dataless": false, "durationMs": 214, "generatedAt": "", "pdfa": { "requested": "none", "converted": false, "verified": false, "validator": null }, "compression": null, "trust": { "qeSeal": { "requested": false, "applied": false }, "qualifiedTimestamp": { "requested": false, "applied": false }, "padesProfile": null } }, "sign": null, "flow": null, "credits": { "items": [ { "unit": "document", "quantity": 1, "credits": 2, "paidBy": "credit" } ], "used": 2, "left": 1247 }, "warnings": [], "error": null } } ``` - `output.status` is `succeeded`, `pending` or `failed`. Poll on this rather than on a product's own status string. - `output.credits` is `{items, used, left}`: `used` is what this operation settled (0 for `isTest`, and for a failure that was refunded), `items` what it is made of - one line per unit and payer, `{unit, quantity, credits, paidBy}`, `paidBy` `credit` or `plan` (a plan's counter, at 0 credits) - and `left` your balance from the ledger after it settled. Never compute `left` as your previous `left` minus `used`. Everything is paid when the work starts: an envelope pays its signature and its seal and timestamp (if asked, from a qualified provider; a non-qualified one is free) when it is sent; its `used` grows afterwards only by the SMS rate (2 credits) per one-time code the gateway accepted for an `sms` signer (a code that did not leave is not charged). A PDF answer carries `X-Credits-Used` and `X-Credits-Left`; an error answer carries `{used, left}`, or `null` when it cannot be said. - `output.warnings` is where a call that succeeded says it did less than you asked. Read it. - `settings` comes back **settled**: what actually applied after the organisation's defaults and gates, which is not always what you sent. - `data` is never echoed back. `output.input` carries its SHA-256 and size instead. ## Signing a PDF you already have, and verifying a signed file Signer Fly works without Composer Fly. Prefix `/api/v1/sign`, scope `signatures:ses`. The signer signs on a page **hosted by DoConure at a secure public URL**; embedded or iframe signing is not built and not promised. | Call | What | |---|---| | `POST /api/v1/sign/files` | `{fileName, fileBase64}`: a PDF, validated whole (not encrypted, no JavaScript, launch or submit action, no attachment, at most 200 pages, within the size limit). 201 with `output.sign.file.fileId`; identical bytes in your organisation answer 200 with the same `fileId`. Free. Refusal: `FILE_REJECTED` with `details.reason`. | | `GET /api/v1/sign/files[/{id}]` | Your PDFs and one file's facts (never its bytes). | | `DELETE /api/v1/sign/files/{id}` | `STORED_FILE_IN_USE` while a draft, sent or in-progress envelope uses it. | | `POST /api/v1/sign/envelopes[?send=true]` | `settings.documents[]` takes `{documentId}`, `{fileId[, sha256]}` or `{fileBase64, fileName}`; `sha256` pins the file (`SOURCE_FILE_CHANGED`). `?send=true` creates and sends in one call (the billable moment). | - A file is referenced, never copied, and never changes: a new version is a new upload and a new `fileId`. Another organisation's `fileId` is `STORED_FILE_NOT_FOUND` (404). - Fields: `signature`, `name`, `date` are marks; `text`, `checkbox` and `dropdown` are questions the signer answers (`label` required; `required` default true; `options` for a dropdown; `value` prefills; `locked` fixes it). Answers come back in `recipients[].fields[].answer`. - **Where a signer signs - a tag, by default:** write `{{ signature.A }}` (`.name`, `.date`, `.box(width, height)`) in the template, `A` being the key in `signers`, and send that signer with no `fields`. The tag is located in the PDF and removed before it is hashed or stored. Coordinates (`fields[]`: `page`, `x`, `y` as fractions from the top-left) are the alternative and win. A tag for a signer not in the request is `SIGNATURE_TAG_UNKNOWN_SIGNER`; with tags in use, a signer with neither is `SIGNER_NOT_PLACED` - both before a flow composes or charges (a tag hidden by a false condition, or typed in an uploaded annex, is only known after composing: the document is then charged and named). An uploaded PDF is scanned for typed tags only with `documents[].signatureTags: true`. - **Callbacks that authenticate:** `callbackUrl: {env, onSuccess, onError}` names one of the organisation's callback environments (console > Environments: base URL + Bearer token, API key in a header, or OAuth2 client credentials) and sends only paths. Without `env`, paths go to the default environment; a full `https://` URL works as before. Unknown `env` `CALLBACK_ENVIRONMENT_UNKNOWN`; a path that could change the host, or a full URL with `env`, `CALLBACK_PATH_INVALID`. The HMAC stays on every callback. - `settings.isTest: true` on an envelope spends the test allowance, never credit, and refuses an SMS code, a seal or a timestamp with `FEATURE_NOT_AVAILABLE_IN_TEST_MODE` before anything is created. - **Verify a signed file, no key:** `GET /verify/{sha256}.json` or `POST /verify.json` (multipart `document`). The answer states each fact apart - `found`, `integrity.result`, the `signature` level, whether a `seal` and a `timestamp` were requested and applied, the evidence `chain` - and never a single `valid`; `seal.qualified` is always `null`. Rate limited per address. - A flow's answer carries `output.flow.statusUrl`, `eventsUrl`, `terminal` and `updatedAt`. ## Bulk composition and signing Many documents from one template, many envelopes, or many flows, asynchronously. Prefix `/api/v1/bulk`; `compose` is governed by the composition switch, `sign` by the signatures switch and `flow` by the flow switch; the token needs `bulk:submit` **and** the single call's scope (`documents:compose` for `compose`, `signatures:ses` for `sign`, `flow:*` for `flow`), `bulk:read` to read jobs and, for the results ZIP, `documents:download` too. Bulk adds no rules of its own: every item goes through the same code, with the same refusals and error codes, as the single call. | Call | What | |---|---| | `POST /api/v1/bulk/compose` | `{settings, items:[{externalId?, data}]}` or `{settings, fileId}`, optional `options.maxItems`. Answers 202 with the job. Send an `Idempotency-Key`. | | `POST /api/v1/bulk/sign` | `{settings, items:[{externalId?, signers, documents?, documentId?, subject?, message?}]}` or `{settings, fileId}`; `settings` is the shared part of an envelope's `settings` (`documents`, `subject`, `isTest`, ...). One envelope per item, created and sent as `POST /api/v1/sign/envelopes?send=true` does. Optional top-level `callbackUrl`. Answers 202 with the job. | | `POST /api/v1/bulk/flow` | `{settings, signature, items:[{externalId?, data, signers}]}`: one flow per item, composed and put out for signature as `POST /api/v1/flow` does; `signature` is the shared part of the signature block. Answers 202 with the job. Send an `Idempotency-Key`. | | `POST /api/v1/bulk/files` | Raw body, JSONL (one `{externalId?, data}` per line) or CSV (`Content-Type: text/csv`, header row; `?delimiter=`, `?encoding=`); answers 201 with a `fileId`. A bad CSV row becomes a failed, uncharged item. | | `GET /api/v1/bulk/jobs/{id}` | The job: status, counts, credits, reconciliation. Poll this. | | `GET /api/v1/bulk/jobs/{id}/items` | Per-item results; `status`, `limit` (max 500), `after` = the previous `nextAfter`. | | `GET /api/v1/bulk/jobs` | Your jobs, newest first. | | `GET /api/v1/bulk/jobs/{id}/results` | A ZIP of every produced PDF plus `manifest.json` and `results.csv`; `after`, `limit` fetch a large job in ranges. | | `POST /api/v1/bulk/jobs/{id}/cancel` | Unstarted items are released; an item in flight finishes. | | `POST /api/v1/bulk/jobs/{id}/retry` | Puts the retryable failed items back as new attempts in the same job; never an item whose provider outcome is unknown. Send an `Idempotency-Key`. | - The job is in `output.bulk` (a fourth `output` key, present only under this prefix). `status`: `queued`, `running`, then `succeeded`, `partially_succeeded`, `failed` or `cancelled`. `output.status` is `pending` until the job ends, then `succeeded` only if every item delivered. Read the items for the detail. - The batch is admitted whole or refused whole (never trimmed), and its credit is reserved in one step before anything renders. `output.credits.used` is the total the items settled; `creditsReserved`, `creditsConsumed`, `creditsHeld` and `creditsReleased` break it down, and a finished job carries a `reconciliation`. - **Only a delivered document or sent envelope is charged.** A failed or cancelled item is refunded. The platform never retries by itself: call `/retry` (retryable failures only, at most 3 attempts per item), or resubmit the rest in a new batch. - A top-level `callbackUrl: {onSuccess, onError}` (https, or `env` and paths for a callback environment) is a **completion webhook**: the same signed, retried, SSRF-guarded delivery as a signature callback (`X-Signer-Signature`, `event.id` stable across retries, types `bulk.succeeded|partially_succeeded|failed|cancelled`), with the job's counts and credits and no item data. - `settings.isTest` runs the batch in the sandbox: no credit, one test-allowance item per document, a small per-batch cap. - Refused whole with `FEATURE_NOT_AVAILABLE_IN_BULK` (1010): `qeSeal`, `qualifiedTimestamp`, `password`, `responseFormat` other than `pdf`, `filename`; for `sign`, a seal or timestamp (asked for or required by the organisation). `isTest` with a paid feature is `FEATURE_NOT_AVAILABLE_IN_TEST_MODE` (1009), as singly. - `BULK_RESULTS_UNAVAILABLE` (1504) and `BULK_RETRY_NOT_POSSIBLE` (1505) carry `details.reason`. - Limit refusals: `BULK_ITEM_LIMIT_EXCEEDED` (1811), `JOB_TOO_LARGE` (1812), `BULK_CONCURRENCY_LIMIT_REACHED` (1813, retryable). Malformed input: `BULK_INPUT_INVALID` (1107). - Fetch each document with `GET /api/v1/compose/documents/{documentId}` (the item's `downloadUrl`). Prose and every limit: https://app.doconure.com/docs/bulk ## Retrying safely Every `POST` that changes something accepts an **`Idempotency-Key`** header - composing a document, creating, sending, resending, replacing a signer of, or cancelling an envelope, uploading a template or a version, activating one, submitting, uploading for, cancelling or retrying a bulk job, and the flow. A second request with the same key returns the first one's answer instead of doing the work again, and the replay carries `Idempotent-Replay: true`. `POST /api/v1/flow` is the exception, and it needs nothing extra from you: it takes the same header and enforces it with a unique constraint on the flow row itself, which is a stronger guarantee than a cached response. - Use a key from the caller's own domain (an order number, a record id). A fresh UUID per attempt defeats the purpose. - The key is scoped to the organisation, the endpoint and the exact request body. Same key with a different body answers `IDEMPOTENCY_KEY_REUSED` (1104) rather than the other call's result. - While the first request is still running, the second answers `IDEMPOTENCY_IN_PROGRESS` (1105), which is **retryable** and carries `Retry-After`. - A failed attempt is never recorded, so the next attempt runs for real. Keys are forgotten after 24 hours. - A replay of a `dataless` generation answers `IDEMPOTENT_RESULT_GONE` (1106), in `pdf` and in `json` mode alike: nothing was kept, not even the answer, and `details.jobId` says what you were charged for. Keep the document from the first response. ## Failures Every failure, from every endpoint, answers the same body: ```json {"code": "TEMPLATE_VERSION_NOT_ACTIVE", "number": 1204, "message": "a human hint; may be reworded, never match on it", "details": {"requestedVersion": 2, "activeVersions": [1, 3]}, "retryable": false} ``` **Branch on `code`. Retry only when `retryable` is true.** The `number` is the same outcome in four digits, to quote when you write to us from the contact page (`/contatti`). What changed in each version is on `/release-notes`. | Number | Code | HTTP | Retryable | When | |---|---|---|---|---| | 1001 | `MISSING_CREDENTIALS` | 401 | no | No `Authorization: Bearer` header on a call that requires one. | | 1002 | `INVALID_API_KEY` | 401 | no | The key is unknown or revoked — deliberately the same answer for both, so the API cannot confirm that a key once existed. | | 1003 | `IP_NOT_ALLOWED` | 403 | no | The key is valid; the address it arrived from is not on the organisation's allowlist. `details.address` echoes it back. | | 1004 | `TENANT_INACTIVE` | 403 | no | The key is valid and the organisation it belongs to cannot act: suspended, a registration not yet approved (`details.registration`), archived after a long period without use (`details.lifecycle` is `archived`; it can ask to be reactivated) or anonymised (`details.lifecycle` is `anonymised`; it cannot come back). | | 1005 | `SUITE_NOT_ENABLED` | 403 | no | The product you called is not active for this organisation. `details.enabled` lists the ones that are. | | 1006 | `ADMIN_LOGIN_REQUIRED` | 401 | no | A console page was reached without a session. Never returned to an API caller. | | 1007 | `FEATURE_NOT_ENABLED` | 403 | no | A paid capability was asked for that this organisation has not bought (qualified seal, qualified timestamp, SMS one-time code, a bulk operation, a signature level) — refused, never produced without it. `details.feature` names which one. | | 1008 | `TOKEN_SCOPE_NOT_ALLOWED` | 403 | no | The organisation may call this; the token itself was not granted the scope it needs. `details.missingScopes` names what is missing. | | 1009 | `FEATURE_NOT_AVAILABLE_IN_TEST_MODE` | 422 | no | `isTest: true` was combined with a feature that is paid for at an external provider (qualified seal, qualified timestamp, SMS one-time code, AES/QES). Refused before anything is reserved or sent. `details.features` names each. | | 1010 | `FEATURE_NOT_AVAILABLE_IN_BULK` | 422 | no | A bulk batch asked for something a bulk job cannot do yet: a seal, a qualified timestamp, a document password, a `responseFormat` or a `filename`. Refused whole, before anything is reserved. `details.fields` names each. | | 1011 | `REQUIRED_FEATURE_NOT_AVAILABLE_IN_TEST_MODE` | 422 | no | `isTest: true` on an organisation whose own settings require a seal or a qualified timestamp on every document. A test call never buys one and a document the policy says must be sealed is never returned unsealed, so the call is refused before anything is reserved or sent. Send it without `isTest`, or change the setting. `details.features` names each. | | 1012 | `TIMESTAMP_REQUIRES_SEAL` | 422 | no | A qualified timestamp was asked for (or forced by the organisation's `timestamp_mode: always`) without a qualified seal. The timestamp is an option of the seal and is never applied alone. Refused before anything is reserved or sent. Ask for `qeSeal` as well, or drop `qualifiedTimestamp`; a sealed document without a timestamp is fine. `details.source` says whether the request or the organisation's setting asked. | | 1101 | `INVALID_REQUEST_BODY` | 422 | no | The body parsed but does not match the expected shape. `details.fields` names each offending field. | | 1102 | `INVALID_JSON` | 400 | no | The body is not parseable JSON. | | 1103 | `DATALESS_NOT_AVAILABLE` | 422 | no | `settings.dataless` was sent to a call that has to keep the document — today that is `/api/v1/flow`. | | 1104 | `IDEMPOTENCY_KEY_REUSED` | 422 | no | The same Idempotency-Key arrived with a different request. Use a new key, or send the same request. | | 1105 | `IDEMPOTENCY_IN_PROGRESS` | 409 | yes | A request with this key is still running. Retry after the interval in the Retry-After header. | | 1106 | `IDEMPOTENT_RESULT_GONE` | 410 | no | The key is known and its result cannot be served again — nothing was kept. You were charged once; `details.jobId` says for what. | | 1107 | `BULK_INPUT_INVALID` | 422 | no | A bulk batch or its uploaded file is malformed as a whole and was refused before anything was reserved. `details.problems` names each, by line or index. | | 1108 | `JSON_STRUCTURE_LIMIT_EXCEEDED` | 422 | no | The JSON body is nested deeper, or holds more values, than the platform reads, and was refused before it was parsed. `details.limit` names which (`depth` or `nodes`), with the figure in force. Send less structure: a long list belongs in a bulk job or a stored file, not in one body. | | 1109 | `IDEMPOTENCY_KEY_REQUIRED` | 422 | no | A qualified seal was asked for without an `Idempotency-Key`. A retry could not be told from a new request and would buy a second seal, which cannot be reversed. Send the header; nothing was reserved or sent. | | 1121 | `FILE_REJECTED` | 422 | no | An uploaded file was refused: empty, not an image or a PDF by its bytes, unreadable, encrypted, with active content, or too many pages. `details.reason` names which; nothing was stored. | | 1201 | `TEMPLATE_NOT_FOUND` | 404 | no | No template with that id, or it belongs to another organisation — indistinguishable on purpose. | | 1202 | `TEMPLATE_DEACTIVATED` | 409 | no | The template exists and has been switched off entirely. | | 1203 | `TEMPLATE_VERSION_NOT_FOUND` | 404 | no | You pinned a version number that was never created. `details.activeVersions` lists what exists. | | 1204 | `TEMPLATE_VERSION_NOT_ACTIVE` | 409 | no | You pinned a version that exists and is not active. Refused, never quietly substituted with another. | | 1205 | `NO_ACTIVE_VERSION` | 409 | no | The template is on and no version of it is active. | | 1301 | `TEMPLATE_FILE_INVALID` | 422 | no | The stored template file could not be read or merged. | | 1302 | `TEMPLATE_FILE_REJECTED` | 422 | no | Refused at upload: macros present, a suspicious archive, or past the size limit. | | 1303 | `JSON_SCHEMA_INVALID` | 422 | no | The jsonSchema sent with a template is not valid, uses a keyword this service does not enforce, or is past the caps. | | 1401 | `MISSING_TEMPLATE_FIELD` | 422 | no | Fields the template requires are absent, and this template is configured to reject incomplete data. | | 1402 | `TEMPLATE_DATA_MISMATCH` | 422 | no | A field is present and of the wrong kind, so the render cannot run at all. Leniency covers absent fields, not wrong ones. | | 1403 | `PAYLOAD_SCHEMA_VIOLATION` | 422 | no | The payload does not satisfy the template's JSON Schema and the template is set to reject. Nothing was charged. | | 1501 | `DOCUMENT_NOT_FOUND` | 404 | no | No document with that id, or it belongs to another organisation. | | 1502 | `BULK_JOB_NOT_FOUND` | 404 | no | No bulk job with that id, or it belongs to another organisation. | | 1503 | `BULK_FILE_NOT_FOUND` | 404 | no | No uploaded bulk file with that id for this organisation, or it was already used by a job, or it expired. | | 1504 | `BULK_RESULTS_UNAVAILABLE` | 409 | no | The result bundle of a bulk job cannot be built: the job has not finished, or its per-item results were deleted by retention. `details.reason` says which. | | 1505 | `BULK_RETRY_NOT_POSSIBLE` | 409 | no | The failed items of a bulk job cannot be put back: it has not finished, nothing in it is retryable, the payloads were not kept, or its results expired. `details.reason` says which; an item whose outcome is unknown is never repeated. | | 1520 | `STORED_FILE_NOT_FOUND` | 404 | no | No stored file with that id for this organisation: unknown, deleted, expired or another organisation's - indistinguishable on purpose. | | 1521 | `DOWNLOAD_LINK_NOT_FOUND` | 404 | no | No such document or download link for this organisation, including one belonging to another. | | 1522 | `DOWNLOAD_LINK_INVALID` | 404 | no | The download address is unknown, altered, revoked or already used. One answer for all, so it cannot be probed. | | 1523 | `DOWNLOAD_LINK_EXPIRED` | 410 | no | The download address was genuine and has expired. Ask for a new one. | | 1601 | `SIGNATURE_LEVEL_UNAVAILABLE` | 501 | no | AES or QES was asked for. They are coming soon (next wave); only SES is produced today, and the request is refused rather than downgraded. | | 1602 | `ENVELOPE_NOT_FOUND` | 404 | no | No envelope with that id, or it belongs to another organisation. | | 1603 | `ENVELOPE_NOT_EDITABLE` | 409 | no | The envelope has already been sent. One composition, one sending. | | 1604 | `ENVELOPE_CLOSED` | 409 | no | Completed, cancelled, declined or expired. `details.status` says which. | | 1605 | `ENVELOPE_NOT_CLOSED` | 409 | no | The evidence bundle was asked for before the matter was settled one way or the other. | | 1606 | `DOCUMENT_NOT_SIGNABLE` | 422 | no | The document exists and is not a PDF. | | 1607 | `DOCUMENT_SOURCE_UNAVAILABLE` | 501 | no | Retired 2026-10-04 and kept so the number is never reused: an uploaded PDF (`fileBase64`, or a `fileId`) is now a supported envelope source and nothing raises this any more. | | 1608 | `SIGNING_LINK_INVALID` | 404 | no | Signer-facing. The link is unknown, already spent, or was killed by a cancellation. | | 1609 | `SIGNING_LINK_EXPIRED` | 410 | no | Signer-facing. Past the envelope's deadline. | | 1610 | `SIGNING_OUT_OF_TURN` | 409 | no | Signer-facing. A sequential envelope, and it is somebody else's turn. | | 1611 | `SIGNATURE_TYPE_NOT_ALLOWED` | 422 | no | The way of making the mark is one the organisation does not permit. Never substituted for an allowed one. | | 1612 | `RECIPIENT_ALREADY_SIGNED` | 409 | no | Signer-facing. That person has already signed; links are single use. | | 1613 | `DELEGATION_NOT_ALLOWED` | 403 | no | Signer-facing. Passing the turn on is forbidden for this envelope, or it has already changed hands often enough. | | 1614 | `OTP_REQUIRED` | 403 | no | Signer-facing. There is no live code — ask for one first. | | 1615 | `OTP_INVALID` | 403 | no | Signer-facing. Wrong code, and the attempt is counted. | | 1616 | `OTP_TOO_MANY_ATTEMPTS` | 429 | no | Signer-facing. Too many wrong guesses on one code, or too many codes asked for. A fresh code resets the guesses. | | 1617 | `RECIPIENT_NOT_FOUND` | 404 | no | No such recipient on that envelope, including one belonging to another organisation. | | 1618 | `VALIDATION_MODE_UNAVAILABLE` | 501 | no | A `validationMode` the contract accepts and this service does not perform. | | 1619 | `RECIPIENT_NOT_REQUIRED` | 409 | no | Signer-facing. This recipient's group signs OR, and somebody else in it already did — their own signature was never required. | | 1620 | `STORED_FILE_IN_USE` | 409 | no | An open envelope still uses this stored file, so it cannot be deleted. `details.envelopeIds` names them. | | 1621 | `SOURCE_FILE_CHANGED` | 409 | no | The `sha256` pinned for a source file is not the file's. A source file never changes; upload the new version as a new file. | | 1622 | `SIGNER_FIELD_INVALID` | 422 | no | Signer-facing. A field the signer had to answer was left empty or answered with a value it does not allow. The signature was not taken. `details.fields` names each. | | 1623 | `CALLBACK_DELIVERY_NOT_FOUND` | 404 | no | No webhook delivery with that id, or it belongs to another organisation. | | 1624 | `SMS_VALIDATION_UNAVAILABLE` | 422 | no | A signer was asked to validate by SMS and the code cannot be delivered: no SMS provider is configured, or the number is outside the registered countries. Refused at creation. `details.reason` and `details.signer` say which. | | 1625 | `CALLBACK_ENVIRONMENT_UNKNOWN` | 422 | no | `callbackUrl.env` names no callback environment of this organisation, or a path was sent without `env` and no environment is the default. Refused at the call; nothing was created. | | 1626 | `CALLBACK_PATH_INVALID` | 422 | no | A callback path that could change the host (a scheme, `//`, `@`, `..`, a backslash or a fragment), a full URL sent with `env`, or a path mixed with a full URL. `details.reason` says which. | | 1627 | `CALLBACK_ENVIRONMENT_IN_USE` | 409 | no | The callback environment is still named by open envelopes, running batches or queued callbacks, so it cannot be deleted. `details` counts them. | | 1628 | `CALLBACK_ENVIRONMENT_INVALID` | 422 | no | A callback environment could not be saved: the sigla, the base URL, the type of authentication or its credentials. `details.field` names which. | | 1629 | `SIGNATURE_TAG_UNKNOWN_SIGNER` | 422 | no | A signature tag in the document (`{{ signature.C }}`) names a signer the request does not have. `details.tags` and `details.signers` say which; a flow is refused before anything is composed or charged. | | 1630 | `SIGNER_NOT_PLACED` | 422 | no | The document places its signers by signature tags, and a signer has neither a tag nor `fields`, or its tag was not found in the finished document. `details.signers` names them. | | 1701 | `FLOW_NOT_FOUND` | 404 | no | No flow with that id, or it belongs to another organisation. | | 1702 | `FLOW_CLOSED` | 409 | no | The flow has already finished, been cancelled, or expired. | | 1801 | `QUOTA_EXCEEDED` | 429 | no | The prepaid balance has run out: it does not cover the operation. | | 1802 | `RATE_LIMIT_EXCEEDED` | 429 | yes | More calls in the last minute than this organisation's limit. Carries `Retry-After` and the `X-RateLimit-*` headers. | | 1803 | `PAYLOAD_TOO_LARGE` | 413 | no | The body is over the configured limit. | | 1804 | `SERVER_BUSY` | 503 | yes | Every conversion slot on this instance is in use. A shared ceiling, not your account's. Carries `Retry-After`. | | 1805 | `SERVICE_PAUSED` | 503 | yes | An operator has paused the service. Temporary, nothing to do with this request, and the identical call will work once it resumes. Carries `Retry-After`. | | 1806 | `RECIPIENT_VOLUME_EXCEEDED` | 429 | yes | Sending this envelope would take the organisation over the number of addresses it may write to in 24 hours. The envelope is untouched and nothing was charged: send it tomorrow, or buy credit, which raises the ceiling. | | 1807 | `TEST_QUOTA_EXCEEDED` | 429 | no | The shared daily test-item pool (`composer.test_quota`) has nothing left for this call. Nothing was reserved or charged: the pool resets at UTC midnight, or an administrator can raise the daily limit. | | 1808 | `TEST_CAPACITY_TEMPORARILY_LIMITED` | 503 | yes | A test call was turned away to protect production capacity. Nothing was reserved or spent; retry after `Retry-After`. | | 1809 | `QUEUE_CAPACITY_EXCEEDED` | 503 | yes | The line for a render slot in this request's class is full. Refused before any work or reservation; carries `Retry-After`. | | 1810 | `TENANT_CONCURRENCY_LIMIT_REACHED` | 429 | yes | This organisation already holds or awaits as many render slots as one organisation may. Others are unaffected; carries `Retry-After`. | | 1811 | `BULK_ITEM_LIMIT_EXCEEDED` | 422 | no | A bulk batch has more items than the effective limit, or an `isTest` batch more than the test cap. Refused whole, never trimmed; `details` has `requestedItems` and `allowedItems`. | | 1812 | `JOB_TOO_LARGE` | 413 | no | A bulk body or uploaded file is over its byte limit. Refused before anything is stored or reserved. | | 1813 | `BULK_CONCURRENCY_LIMIT_REACHED` | 429 | yes | This organisation already has as many bulk jobs running, items queued, unused uploads or submissions in the last minute as the platform allows. Retry when one finishes; `details.what` says which. | | 1814 | `MAINTENANCE_MODE` | 503 | yes | The platform is in planned maintenance, or about to be and is finishing the bulk jobs already running. Nothing was done or charged; the identical call works once it ends. Carries `Retry-After`. | | 1815 | `PURCHASE_AMOUNT_TOO_LARGE` | 422 | no | One credit purchase is more than EUR 1,000.00, VAT excluded. Refused before any order or payment: make several purchases, or contact us for a larger amount. | | 1816 | `SUBSCRIPTION_ALLOWANCE_EXHAUSTED` | 429 | no | A counter of the plan (signatures, documents or calls) is used up and paying with credit after it is off. Nothing was reserved or charged: turn on paying with credit in the console, wait for the next period, or renew. | | 1830 | `FILE_LIMIT_REACHED` | 429 | no | This organisation already keeps as many stored files as the platform allows. Delete some, or wait for them to expire. | | 1901 | `RENDERING_FAILED` | 500 | no | The merge step failed for a reason that is not the payload's. | | 1902 | `CONVERSION_FAILED` | 500 | no | PDF conversion failed, or a `settings`-driven post-processing step did. | | 1903 | `CONVERSION_TIMEOUT` | 504 | yes | Conversion or post-processing ran past its time limit. | | 1904 | `STORAGE_UNAVAILABLE` | 503 | yes | Template or document storage could not be reached. | | 1905 | `INTERNAL_ERROR` | 500 | yes | Anything unclassified. Ours to fix, and never the caller's to work around. | | 1906 | `TRUST_PROVIDER_NOT_CONFIGURED` | 503 | no | The seal or timestamp service is not configured on this deployment. Your organisation is entitled to it; we cannot perform it today. | | 1907 | `TRUST_PROVIDER_UNAVAILABLE` | 503 | yes | The seal or timestamp provider could not be reached, or refused us. Retryable. `details.category` says which. | | 1908 | `SEAL_FAILED` | 502 | no | A seal was asked for and could not be applied, so no document is returned. Nothing was charged for the seal. | | 1909 | `TIMESTAMP_FAILED` | 502 | no | A qualified timestamp was asked for and could not be applied, so no document is returned. Nothing was charged for it. | | 1910 | `BULK_JOB_TIMED_OUT` | 504 | yes | A bulk item was still waiting when its job passed its deadline, so it was not run. Only seen on an item; nothing was charged. Put it in a new batch. | | 1911 | `PROVIDER_BILLING_PROFILE_INCOMPLETE` | 503 | yes | The seal provider refused because the platform's own account with it is not complete. Ours to fix; you are told the service is temporarily unavailable, nothing is charged for the seal, and the identical request works once it is fixed. | ### Warnings A 2xx that did less than it was asked. Never an error, and never silent. | Number | Code | When | |---|---|---| | 2001 | `SEAL_NOT_ENABLED` | A seal was asked for and the organisation does not seal. The document is produced unsealed. | | 2002 | `TIMESTAMP_NOT_ENABLED` | A qualified timestamp was asked for and the organisation does not add one. The document is produced without it. | | 2003 | `SEAL_REQUIRED_BY_ORGANISATION` | Sealing was declined and the organisation seals everything. It is sealed, and charged for. | | 2004 | `TIMESTAMP_REQUIRED_BY_ORGANISATION` | A timestamp was declined and the organisation timestamps everything. It is applied, and charged for. | | 2005 | `SEAL_NOT_CONFIGURED` | Wanted by everybody, and this platform holds no sealing certificate. Ours, not yours. | | 2006 | `TIMESTAMP_NOT_CONFIGURED` | Wanted by everybody, and this platform has no timestamp authority configured. Ours, not yours. | | 2007 | `SEAL_NOT_APPLIED` | Configured, wanted, attempted at completion, and it did not work. The signature still stands. | | 2008 | `TIMESTAMP_NOT_APPLIED` | The timestamp authority was asked at completion and did not answer. The envelope still completed: a third party being down never costs a signature that has been given. | | 2009 | `SEAL_NOT_ENTITLED` | Your organisation seals everything, and the qualified seal is not active on the account. Produced without it; nothing was charged. | | 2010 | `TIMESTAMP_NOT_ENTITLED` | Your organisation timestamps everything, and the qualified timestamp is not active on the account. Produced without it; nothing was charged. | | 2101 | `PAID_EXTRA_SKIPPED_ON_TEST` | A test organisation, or an `isTest` call whose organisation gate forces on something that costs real money at a third party, where the setting is not a requirement. Skipped before it is bought. A call that itself asks for it with `isTest` is refused with 1009; one whose setting is `always` with 1011. | | 2102 | `DELEGATION_NOT_ENABLED` | Delegation was asked for and the organisation forbids it. The envelope goes out without it. | | 2103 | `DELEGATION_REQUIRED_BY_ORGANISATION` | Delegation was declined and the organisation always allows it. | | 2104 | `TEMPLATE_FIELDS_MISSING` | The template reads fields the payload did not carry. They rendered empty; `details.fields` names them. | | 2105 | `RENDER_NOTES` | The renderer had something to say about this document — a shortened value, a section that did not appear. | | 2106 | `PAYLOAD_SCHEMA_VIOLATIONS` | The payload does not satisfy the template's JSON Schema; the document was produced anyway. `details.violations` names the paths. | | 2107 | `TRUST_NOT_QUALIFIED` | A seal or timestamp was applied by a provider that is simulated or not declared qualified: genuine in structure, no eIDAS presumption of origin or time. `details.feature` and `details.simulated` say which. | | 2120 | `COMPRESSION_SKIPPED` | `compressionLevel` was asked for and not applied (PDF/A, black-and-white, or no smaller result). `details.reason` says which. | | 2121 | `PDFA_NOT_VERIFIED` | A PDF/A conversion was made and not validated against a PDF/A checker: no conformance is claimed. | | 2130 | `ATTESTATION_REFUNDED_ENVELOPE_CLOSED` | An envelope was cancelled, expired or declined after it was sent, so its seal and timestamp were returned to the credit they came from. The signature is not returned. `details.credits` and `details.units` say what. | | 2131 | `ATTESTATION_REFUNDED_NOT_APPLIED` | The seal or timestamp paid at the send could not be applied at completion (the provider was down or refused), so it was returned at once. The envelope completed without it. | | 2132 | `ATTESTATION_REFUNDED_OUTCOME_UNKNOWN` | The seal's request reached the provider and its answer was lost, so the seal or timestamp was returned at once and the request is not repeated. The envelope completed without it. | | 2201 | `DOCUMENT_NOT_STORED` | The generation was dataless: the response is the only copy there will ever be, and nothing about the request was kept. | | 2202 | `DOCUMENT_STORED_FOR_SIGNATURE` | A dataless organisation sent something for signature, so the document had to be kept. The payload was not. | | 2203 | `DOCUMENT_STORED_FOR_BULK` | A dataless organisation ran a bulk batch, so its documents had to be kept to be fetched. The payloads were not recorded and are wiped as each item finishes. | --- # The guide ## Guide home Source: https://app.doconure.com/docs/ Turn JSON into a finished PDF with one HTTP call, and send it for electronic signature with another. Word, Excel or HTML templates in, PDF out, no SDK to install. ## DoConure DoConure · Integrator guide Turn your data into a finished PDF, using a document model you control. ### How it works You send structured data; the service merges it into a template you uploaded and returns a PDF. The template is an ordinary Word, Excel or HTML file with placeholders where the variable values go. ``` your system (Salesforce, Odoo, a script…) │ │ JSON: { "customer": { "name": "Acme Srl" }, … } ▼ POST /api/v1/compose/documents/generate │ ▼ PDF ``` The service knows nothing about Salesforce or Odoo, deliberately: any system integrates the same way. | Template format | Use it for | | Word (.docx) | Letters, contracts, forms, reports. The format anyone can edit. | | HTML | Precise control over layout, and speed: roughly 7× faster than the others. | | Excel (.xlsx) | Tabular reports with many rows. | | Term | Meaning | | Tenant | One customer. Owns its templates and API keys, and cannot see anyone else's. | | Template | The document model, identified by an id such as invoice-standard-8bc99b. | | Version | Every upload creates one. Several can be active at once — see Versions. | | API key | The tenant's credential, sent on every request. | ### What you can count on What developers complain about in document and signature APIs, and what this one does instead. - No approval to go live. An account is active as soon as you sign up; production needs credit, not a review. - Free tests with the real call. "isTest": true in settings spends no credit: same address, same key. - Every call says what it cost, in output.credits.items or the X-Credits-Used header. Out of credit, the call is refused with a code (Errors), never moved to a pricier plan; a mail warns you first. - Errors you can read. Every outcome has a stable code and number; a template's missing fields are listed by name. - See what happened, and run it again. The console shows each document's payload with a curl command to repeat it, and each webhook's answer, next try and a Send again button; API errors lists what your keys were refused, by code, with a link to each code's row in Errors. - The PDF looks like your Word file. A template's page says which Word fonts are missing and what replaces them (fonts). ### Every page Grouped the way the sidebar groups them — a fast path first, one recipe per task, then everything in full. What changed in each version is on the release notes. A problem or an idea: signed-in customers can report it from the console, and anyone can write to us from the contact page, including a complaint. ## Questions and answers Source: https://app.doconure.com/docs/faq Short answers to what people ask first - keys, test calls, credit, signing, errors and limits - each linking to the page that says the rest. ## Questions and answers Short answers, grouped by topic. Each one links to the page that says the rest. Looking for something else? The search box at the top of the guide’s menu finds it across every page and these answers; signed in, the one on the Help page of the console searches the console’s help too. Italian words work as well (iva, firma, modello). - Getting started - API keys - Testing - Credit and billing - Signing - Errors - Limits ### Getting started #### How do I make my first call? Take the starter key from your welcome email and the id of the Hello World template from the console, and send them to POST /api/v1/compose/documents/generate. The answer is the PDF. Read more #### How do I sign a document? Compose it, then create and send an envelope with POST /api/v1/sign/envelopes?send=true, naming the signer and where the signature goes. Or do both in one call with POST /api/v1/flow. Read more #### Can the data tick a checkbox in a Word template? Yes: give the checkbox a name (the bookmark of a legacy checkbox, or the Tag of a checkbox content control) and send "consenso": true to tick it, false to clear it. Leave the key out and the box stays as the template has it. Read more #### Is there a ready example in my language? Yes: cURL, Python and JavaScript for composing, signing and the one-call flow, each with its test variant, ready to copy. Read more #### Why is the font in my PDF different from my Word file? The PDF uses free fonts, so a Word font we cannot install is replaced. Arial, Times New Roman, Courier New, Calibri and Cambria have a twin with the same widths; others (Aptos, Verdana, Georgia) change a little or a lot. The template's page says which of its fonts are replaced. Read more #### How do I find my way around the console? The search is on the Help page; press / or Ctrl-K from any page to get there. A table sorts when you click its header, once up and once down. Settings are grouped by theme with an index at the top (Branding has its own entry in the menu); a form puts each label on the left when there is room. Read more #### Can an AI agent write the integration? Point Claude Code, Codex or Cursor at /llms-full.txt. The prompt it gets never contains your API key. Read more ### API keys #### How do I get an API key? In the console: API keys, then Issue a key. Give it a name, tick what it may do and choose how long it lasts; it is shown once, so copy it then. Keys are issued only by you, in your console - never by us. Read more #### Which scope does my key need? documents:compose to compose, signatures:ses to sign, flow:* for the flow, and isTest on top for a test call. A refusal names what was missing in details.missingScopes. Read more #### I lost a key. Can you read it back? No: a key is stored only as a hash, so nobody can read it back. Revoke it and issue a new one on the API keys page. Read more ### Testing #### How do I make a test call? Add "isTest": true to settings. Same request, same answer; the PDF carries a red TEST band on every page and has no legal value. Read more #### Do test calls use credit? No. They spend a shared pool of 20 test items a day (unless agreed otherwise), which refills at 00:00 UTC. Every test answer says what is left in output.credits.testRemaining. Read more #### Are test calls counted with real ones? Never. The console, GET /api/v1/usage and the export count production and show test calls apart. Read more #### Can a test call ask for a seal or an SMS code? No. Anything paid at a third party is refused with FEATURE_NOT_AVAILABLE_IN_TEST_MODE before anything is reserved or sent. Read more ### Credit and billing #### What does a document cost? 2 credits at the standard rate, and a credit is 10 cents: EUR 0.20. A signature request is 10 credits, so a flow (compose and sign in one call) is 12; an SMS code, a seal and a timestamp have rates of their own. Yours are on the console dashboard. Read more #### What happens when credit runs out? The call is refused with QUOTA_EXCEEDED, and nothing is produced or charged. Buy more under Billing, Buy credit. Read more #### How do I see what a call cost? output.credits.items lists it line by line - unit, quantity, credits and whether credit or your plan paid - and the lines add up to output.credits.used. left is your balance after it. Read more #### Is there a yearly plan? EarlyBird, when Billing offers it: EUR 899 a year for 1,200 signatures, 2,400 SMS codes and 5,000 documents, used before your credit; seal and timestamp stay in credit. The counters start again at each renewal. Read more #### Can I change or cancel my plan? Cancel at any time under Billing: it runs to its end, then prepaid credit. Upgrade at any time, paying the price difference, same end date. Moving down or to another plan: in its last thirty days. Until five days before the end you can still change your mind. Read more #### Do you keep my card? No. The card number and its code are typed in Revolut's own form and never reach us. For a plan we keep a Revolut token, the last four digits and the expiry. At a payment you use the saved card or add a new one; for a plan, a new card replaces the saved one. Read more #### How do I delete my saved card? Billing, Plan, Card, Delete card (coordinators). It is deleted at Revolut and the plan stops renewing: it ends on the date we email you, then prepaid credit, with the credit you hold. A top-up will need a card again. In the last thirty days you can turn it back on with a new card. Read more #### How much credit can I buy at once? One purchase is at most EUR 1,000.00, VAT excluded; a larger one is refused with PURCHASE_AMOUNT_TOO_LARGE before any payment. Make several purchases, or contact us for a larger amount. Read more #### Which VAT do I pay? 22% in Italy; 0% in any other country (reverse charge in the EU, business or private). If we set another regime for your organisation, that one. Your billing page shows it, read-only. Read more #### A different VAT regime applies to us. What do I do? Tick My VAT treatment is different on the Billing page and say what applies and where it is written. Until we have checked it, purchases are charged at your account's rate; if it holds, we set the regime and customer care may add credit for what was charged. Read more #### Am I charged for a call that failed? No. Only what was delivered is charged: a document, a sent envelope, an SMS the gateway accepted. A refused or failed call gives back what it had reserved. Read more #### Do I get a seal or timestamp back if the envelope is cancelled or the seal fails? Yes. An envelope pays them with the signature at the send. If it is cancelled, expires or is declined, or they cannot be applied when the last signer finishes (also when the provider's answer is lost), they go back at once to the credit they came from, and your audit trail says so. The signature is not given back. Read more ### Signing #### Which signature level do I get? A simple electronic signature (SES), with the evidence kept. AES and QES are coming soon, and are refused with SIGNATURE_LEVEL_UNAVAILABLE until then. Read more #### How do I know a document was signed? Read output.status when you need it, or give a callback URL and take the signed webhook when it happens. Read more #### How do I place the signature? Write {{ signature.A }} in the template where signer A signs, like any other field, and send that signer with no fields. The alternative is fields with page, x and y as fractions of the page from the top-left; they win when given. Read more #### Can your callbacks authenticate at my system? Yes: add a callback environment in the console (Bearer token, API key or OAuth2 client credentials) and send "env" with the paths in callbackUrl. Every callback still carries our HMAC signature. Read more #### Can the mails my signers get carry my logo and colours? Yes: Branding, in the left menu (coordinators). A logo, header, button and footer colours and a footer text; never your own HTML. The code mail stays ours, and a branded mail drops our own sign-off line but keeps the report link. Read more #### Can I write the text of the mails my signers get? Yes: Branding, then Emails. Each mail opens with our own text; edit it in Italian and in English with placeholders such as the signer's name and the link, preview it, send yourself a test and restore the default at any time. Plain text only, checked before saving. The signing pages have a few lines of your own too. Read more #### A signer lost the link. What now? Resend to that signer: POST /api/v1/sign/envelopes/{id}/recipients/{id}/resend, or the Resend button in the console. No new request and no new charge; they get the invitation again with a fresh link, and the old one stops working. Automatic reminders do the same on a timer. Read more #### A signer says the link has expired. What now? The page they see says why: the request expired, was revoked, or they were replaced. While the request is open, resend to them for a fresh link; an expired request cannot be reopened, so send a new one. Signers are told when a request expires or is revoked. Read more #### Can I replace a signer? Yes, from the console or with POST /api/v1/sign/envelopes/{id}/recipients/{id}/replace: the new person takes the fields and the place in the order, the old link stops working. On a draft it just corrects name, email and phone. For an SMS signer give the new mobile. Read more #### How do I put a word in bold in the signing message? Write it between double asterisks: **clause 4**. A line break is a new line and an empty line a new paragraph; links are clickable. In a document, bold comes from the template itself. Read more ### Errors #### What does an error mean? Every failure has a stable code and a four-digit number in output.error. Branch on the code, never on the message; the table says when each one happens and whether to retry. Read more #### Should I retry a failed call? Only when the table says it is retryable, after the seconds in Retry-After. Send an Idempotency-Key, so a retry never charges twice. Read more #### What happens during maintenance? Every call is answered MAINTENANCE_MODE (503, Retry-After) and nothing is done or charged; retry later. Just before, a new bulk job is refused while the running ones finish, and answers carry X-Maintenance-Pending. Read more ### Limits #### What are my limits? Requests per minute, sizes, pages, test calls per day and how long links and files last: in the console under Reference, Limits, or in GET /api/v1/usage. Read more #### I got a 429. What do I do? RATE_LIMIT_EXCEEDED: too many requests this minute, so wait the seconds in Retry-After. TEST_QUOTA_EXCEEDED is a 429 too: today's test pool is spent; and so is QUOTA_EXCEEDED, your credit. Branch on the code, not the status. Read more #### What is the largest request I can send? 25 MB. A larger body is refused with PAYLOAD_TOO_LARGE (1803, HTTP 413) and nothing is received or charged. No limit is above it: a template is at most 25 MB too, and the limits of your organisation for the payload, an uploaded file or a bulk file are under Reference, Limits. Read more #### How long do you keep my documents? A generated document 12 months (a test one 7 days), what you sent as long, an uploaded file 30 days, signature evidence 10 years. A dataless call keeps nothing at all. Read more #### Which time zone are the times in? The API and webhooks: always UTC. The console, the mails, the certificate, the line under each signature and the exports: your organisation's locale, its zone and date format, which we set for you; write to us to change it. Read more ## How do I make my first call? Source: https://app.doconure.com/docs/quickstart-document From a new account to a PDF on disk in one request: the starter key, the Hello World template and one cURL, with what comes back and the first-call errors. ## How do I make my first call? From a new account to a PDF on disk in one request. Every account starts with a template called Hello World and a starter API key named hello-world, so there is nothing to build first. - Get your API key. The starter key arrives by email when your account is created; it is never shown on screen. Lost it, or need more than composing? In the console open API keys and issue a new one, giving it a name: it is shown once, so copy it before you leave the page. Keep it in an environment variable rather than in your code: export DOCONURE_API_KEY="paste-your-key-here" - Get the template id. In the console open Templates → Hello World; the id is on the template's page. - Write the request to a file called request.json. It has two parts: data, the values the template reads, and settings, which template to use: { "data": { "title": "Order confirmation", "reference": "ORD-1042", "date": "2026-09-19", "customer": {"name": "Rossi Costruzioni Srl", "email": "ufficio@example.com"}, "lines": [ {"description": "Site survey", "quantity": 1, "price": "450.00", "amount": "450.00"}, {"description": "Structural report", "quantity": 2, "price": "300.00", "amount": "600.00"} ], "total": "1050.00", "note": "Payment within 30 days." }, "settings": {"templateId": "YOUR_TEMPLATE_ID"} } - Call the API: curl -X POST https://app.doconure.com/api/v1/compose/documents/generate \ -H "Authorization: Bearer $DOCONURE_API_KEY" \ -H "Content-Type: application/json" \ -d @request.json \ --dump-header headers.txt \ --output document.pdf ### What comes back On success the answer is 200 and the body is the PDF itself, written to document.pdf. A PDF has no room for anything else, so the rest is in the headers (headers.txt above): - X-Credits-Used and X-Credits-Left: what this document cost and what your organisation has left; - X-Document-Id: the stored document, to download again or to send for signature; - X-Template-Version: which version of the template produced it. Prefer JSON? Add "responseFormat": "json" to settings: the answer then carries an output object with the same facts. ### If it does not work An error is always JSON with a stable code, never a bare status. The usual first-call ones: - 401 INVALID_API_KEY: the key is missing, mistyped, expired or revoked. Check that $DOCONURE_API_KEY is set in the shell you run cURL from. - 404 TEMPLATE_NOT_FOUND: the id belongs to another organisation or does not exist. Copy it again from the template's page. - 429 QUOTA_EXCEEDED: not enough credit left for this document. The console shows your balance; nothing was charged. Every code, with what to do about it: Errors. No code needed to try a template. Open the template in the console and use Test with JSON. The field is pre-filled from the template's own placeholders, so the shape is already correct — press the button and download the PDF. Full field list for this call: Generating a document. Ready to send it for signature too? See Your first signature or, to do both in one request, Compose and send for signature in one call. ## How do I make a test call? Source: https://app.doconure.com/docs/howto-test Add isTest to settings: the same request, nothing charged, the document marked TEST. Ready requests for a document, a signature and a flow, the daily test pool and what a test call cannot do. ## How do I make a test call? Add "isTest": true to settings. Same request, same endpoint, same answer. Nothing is charged, the document is marked TEST on every page, and it has no legal value. Your key needs the isTest scope: the starter key has it, and on a new key it is the box Sandbox calls. ### A test document ``` curl -X POST https://app.doconure.com/api/v1/compose/documents/generate \ -H "Authorization: Bearer $DOCONURE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "data": { "title": "Order confirmation", "customer": { "name": "Rossi Costruzioni Srl" } }, "settings": { "templateId": "YOUR_TEMPLATE_ID", "isTest": true } }' \ --dump-header headers.txt --output test-document.pdf ``` The PDF comes back as usual. X-Credits-Used is 0, and X-Test-Remaining says how many test items are left today. With "responseFormat": "json" the same number is in output.credits.testRemaining. ### A test signature ``` curl -X POST "https://app.doconure.com/api/v1/sign/envelopes?send=true" \ -H "Authorization: Bearer $DOCONURE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: test-envelope-1" \ -d '{ "signers": { "A": { "email": "you@example.com", "fullName": "Your Name", "fields": [{ "kind": "signature", "page": 1, "x": 0.1, "y": 0.78, "width": 0.32, "height": 0.09 }] } }, "settings": { "documentId": "THE_X-DOCUMENT-ID", "subject": "Test", "isTest": true } }' ``` The emails are real, so send them to yourself: each one says [TEST] in the subject and opens with a red warning. The signing page and the signed file carry the same red TEST band. ### A test flow ``` curl -X POST https://app.doconure.com/api/v1/flow \ -H "Authorization: Bearer $DOCONURE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: test-flow-1" \ -d '{ "data": { "title": "Service agreement", "customer": { "name": "Rossi Costruzioni Srl" } }, "settings": { "templateId": "YOUR_TEMPLATE_ID", "isTest": true }, "signature": { "subject": "Service agreement (test)", "signers": { "A": { "email": "you@example.com", "fullName": "Your Name", "fields": [{ "kind": "signature", "page": 1, "x": 0.1, "y": 0.78, "width": 0.32, "height": 0.09 }] } } } }' ``` A test flow is one test item for the document and the signature together. ### Do test calls use credit? No. They spend your organisation’s shared pool of test items: 20 a day unless we agreed another number with you. One call is one item. The pool refills at 00:00 UTC every day, a fixed time you can plan around. You can see what is left in three places: - every test answer: output.credits.testRemaining, or the X-Test-Remaining header on a PDF; - GET /api/v1/usage, in testItems, with the reset moment in resetsAt; - the console’s home page (how long until it refills), and Reference → Limits, which writes the same 00:00 UTC in your organisation’s own time (02:00 in Italy in summer, 01:00 in winter). When the pool is empty, a test call is refused whole with TEST_QUOTA_EXCEEDED (429). Nothing is charged. ### What a test call cannot do A test call never spends money at a third party. Asking for a qualified seal, a qualified timestamp, an SMS code or an AES/QES level together with isTest is refused with FEATURE_NOT_AVAILABLE_IN_TEST_MODE (422) before anything is reserved or sent; details.features names each. Nothing is quietly left out. ### Test and production are counted apart The console, GET /api/v1/usage and the export count production first and show test calls beside it (“1,240 documents · 37 test”), never one number mixing the two. The lists of documents, signatures and flows have an All / Production / Test filter, and every test row has a red TEST badge. The same filter is in the API: GET /api/v1/sign/envelopes?kind=production and GET /api/v1/flow?kind=test (all, production or test). The export of your history (Export in the console menu, a ZIP of CSV files) has an is_test column in every file, including signature_recipients.csv and bulk_items.csv, and its README.txt gives the production and the test totals separately. More: Generating a document, Test envelopes, Ready examples. ## Ready examples: cURL, Python, JavaScript Source: https://app.doconure.com/docs/examples Compose a document, send it for signature, or do both in one call - in cURL, Python and JavaScript, ready to copy, with the test variant of each one line away. ## Ready examples The three things people do, in cURL, Python and JavaScript, ready to copy. Each reads the key from the DOCONURE_API_KEY environment variable, never from the code. For a test version of any of them, add "isTest": true to settings: nothing is charged and the document is marked TEST (How do I make a test call?). Python uses requests (pip install requests); JavaScript needs Node.js 18 or later and runs as an ES module (example.mjs). ### Compose a document #### cURL ``` curl -X POST https://app.doconure.com/api/v1/compose/documents/generate \ -H "Authorization: Bearer $DOCONURE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "data": { "title": "Order confirmation", "customer": { "name": "Rossi Costruzioni Srl" } }, "settings": { "templateId": "YOUR_TEMPLATE_ID" } }' \ --dump-header headers.txt --output document.pdf ``` #### Python ``` import os import requests API = "https://app.doconure.com/api/v1" HEADERS = {"Authorization": f"Bearer {os.environ['DOCONURE_API_KEY']}"} body = { "data": {"title": "Order confirmation", "customer": {"name": "Rossi Costruzioni Srl"}}, "settings": {"templateId": "YOUR_TEMPLATE_ID"}, } r = requests.post(f"{API}/compose/documents/generate", headers=HEADERS, json=body, timeout=120) if r.status_code != 200: err = r.json() raise RuntimeError(f"{err['code']} ({err['number']}): {err['message']}") with open("document.pdf", "wb") as f: f.write(r.content) print("document", r.headers["X-Document-Id"], "credits left", r.headers["X-Credits-Left"]) ``` #### JavaScript ``` import { writeFile } from "node:fs/promises"; const API = "https://app.doconure.com/api/v1"; const headers = { "Authorization": `Bearer ${process.env.DOCONURE_API_KEY}`, "Content-Type": "application/json", }; const r = await fetch(`${API}/compose/documents/generate`, { method: "POST", headers, body: JSON.stringify({ data: { title: "Order confirmation", customer: { name: "Rossi Costruzioni Srl" } }, settings: { templateId: "YOUR_TEMPLATE_ID" }, }), }); if (!r.ok) { const err = await r.json(); throw new Error(`${err.code} (${err.number}): ${err.message}`); } await writeFile("document.pdf", Buffer.from(await r.arrayBuffer())); console.log("document", r.headers.get("X-Document-Id"), "credits left", r.headers.get("X-Credits-Left")); ``` ### Send a document for signature Over a document you composed (its X-Document-Id), created and sent in one call. Always send an Idempotency-Key: this call charges and emails a real person, and a retry without one does both again. #### cURL ``` curl -X POST "https://app.doconure.com/api/v1/sign/envelopes?send=true" \ -H "Authorization: Bearer $DOCONURE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: contract-4471" \ -d '{ "signers": { "A": { "email": "anna@example.com", "fullName": "Anna Rossi", "fields": [{ "kind": "signature", "page": 1, "x": 0.1, "y": 0.78, "width": 0.32, "height": 0.09 }] } }, "settings": { "documentId": "THE_X-DOCUMENT-ID", "subject": "Please sign" } }' ``` #### Python ``` body = { "signers": { "A": { "email": "anna@example.com", "fullName": "Anna Rossi", "fields": [{"kind": "signature", "page": 1, "x": 0.1, "y": 0.78, "width": 0.32, "height": 0.09}], } }, "settings": {"documentId": document_id, "subject": "Please sign"}, } r = requests.post( f"{API}/sign/envelopes", params={"send": "true"}, json=body, timeout=120, headers={**HEADERS, "Idempotency-Key": f"contract-{document_id}"}, ) r.raise_for_status() print("envelope", r.json()["output"]["sign"]["envelopeId"]) ``` #### JavaScript ``` const sent = await fetch(`${API}/sign/envelopes?send=true`, { method: "POST", headers: { ...headers, "Idempotency-Key": `contract-${documentId}` }, body: JSON.stringify({ signers: { A: { email: "anna@example.com", fullName: "Anna Rossi", fields: [{ kind: "signature", page: 1, x: 0.1, y: 0.78, width: 0.32, height: 0.09 }], }, }, settings: { documentId, subject: "Please sign" }, }), }); const answer = await sent.json(); if (!sent.ok) throw new Error(`${answer.code}: ${answer.message}`); console.log("envelope", answer.output.sign.envelopeId); ``` ### Compose and send for signature in one call #### cURL ``` curl -X POST https://app.doconure.com/api/v1/flow \ -H "Authorization: Bearer $DOCONURE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-4471" \ -d '{ "data": { "customer": { "name": "Acme Srl" } }, "settings": { "templateId": "YOUR_TEMPLATE_ID" }, "signature": { "subject": "Service agreement", "signers": { "A": { "email": "anna@example.com", "fullName": "Anna Rossi", "fields": [{ "kind": "signature", "page": 1, "x": 0.1, "y": 0.78, "width": 0.32, "height": 0.09 }] } } } }' ``` #### Python ``` body = { "data": {"customer": {"name": "Acme Srl"}}, "settings": {"templateId": "YOUR_TEMPLATE_ID"}, "signature": { "subject": "Service agreement", "signers": {"A": {"email": "anna@example.com", "fullName": "Anna Rossi", "fields": [{"kind": "signature", "page": 1, "x": 0.1, "y": 0.78, "width": 0.32, "height": 0.09}]}}, }, } r = requests.post(f"{API}/flow", json=body, timeout=120, headers={**HEADERS, "Idempotency-Key": "order-4471"}) r.raise_for_status() flow = r.json()["output"]["flow"] print("flow", flow["flowId"]) ``` #### JavaScript ``` const started = await fetch(`${API}/flow`, { method: "POST", headers: { ...headers, "Idempotency-Key": "order-4471" }, body: JSON.stringify({ data: { customer: { name: "Acme Srl" } }, settings: { templateId: "YOUR_TEMPLATE_ID" }, signature: { subject: "Service agreement", signers: { A: { email: "anna@example.com", fullName: "Anna Rossi", fields: [{ kind: "signature", page: 1, x: 0.1, y: 0.78, width: 0.32, height: 0.09 }], }, }, }, }), }); const flow = await started.json(); if (!started.ok) throw new Error(`${flow.code}: ${flow.message}`); console.log("flow", flow.output.flow.flowId); ``` ### Next - Which scope each call needs: Authentication. - What an error means and when to retry: Outcomes and error codes. - Knowing when a signature completed: How do I know when something finished? ## How do I sign a document? Source: https://app.doconure.com/docs/quickstart-signature Draft a signing envelope, send it, and watch it complete. Two calls, and the second one is the billable moment. ## How do I sign a document? Take the X-Document-Id from generating a document — or do the whole step in one call, see How To: compose and send for signature in one call — and put it out for signature. This is the smallest request that works: one signer, one mark, no options: ``` curl -X POST https://your-host/api/v1/sign/envelopes \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "signers": { "A": { "email": "signer@example.com", "fullName": "Anna Rossi", "fields": [ { "kind": "signature", "page": 1, "x": 0.1, "y": 0.78, "width": 0.32, "height": 0.09 } ] } }, "settings": { "documentId": "PASTE_THE_X-DOCUMENT-ID_HERE", "subject": "Please sign" } }' ``` Where the signature goes. The usual way is a tag in the template, {{ signature.A }}, written like any other field: signer A then needs no fields and signs where the tag is. The alternative is fields, as here: x and y are fractions of the page from the top-left corner (0.1 = a tenth of the way across). How to read them off a PDF, and everything else about placing a signature: Where the signature goes on the page. That call only drafts the envelope — nothing is sent yet, and nothing is charged. The answer is the same envelope every endpoint here answers with; the id you need is output.sign.envelopeId: ``` { "output": { "status": "pending", "sign": { "envelopeId": "7a04...", "status": "draft", "...": "..." }, "compose": null, "flow": null, "warnings": [], "error": null } } ``` Send it: ``` curl -X POST https://your-host/api/v1/sign/envelopes/ENVELOPE_ID/send \ -H "Authorization: Bearer YOUR_API_KEY" ``` That second call is the billable moment, and the point where a real email goes out. Every field you did not set took its default: the signer proves it is them with a slide-to-confirm gesture and no code (validationMode: "sign"), the link is good for a year, and nobody is copied. All of that is adjustable — see Signing on its own for the full field list, or jump straight to a task in the How To section. Signing needs real outbound email to be worth anything. Every signing link is sent by email — there is no other way to reach a signer; only the one-time code can go by SMS instead (validationMode: "sms"). If you send an envelope and nothing arrives, the deployment's own outbox is where the answer is. A message there reads sent once a relay has accepted it and delivered once the recipient's server has, and those are different claims: a wrong address shows up as bounced within seconds rather than as an envelope that simply never completes. On a deployment where no sender is configured at all, messages stay queued and an operator can read the link straight out of one for a live walkthrough. ## How do I get an API key? Source: https://app.doconure.com/docs/authentication A Bearer key per organisation, scoped and expiring. How to issue one, what it may be used for, how to revoke it, and why there is no OAuth dance to implement. ## How do I get an API key? Every API call carries the tenant's key in an HTTP header: ``` Authorization: Bearer lpdc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` There is no login step and no separate token to obtain: the key goes on every request. Never put the key in the URL. A key in a query string ends up in server logs, in intermediate proxy logs and in browser history. Header only. - An organisation can hold several active keys at once, which is how you rotate one with no downtime: issue the new one, update the callers, revoke the old one. - Every key expires. Pick a duration when you issue it — 30, 90, 180, 365 or 730 days, 90 by default — and there is no unlimited option and no grace period after it: an expired key answers exactly like a revoked one. Issue the next key before the old one runs out. - Revocation takes effect immediately — the very next request gets INVALID_API_KEY. There is no grace window. - A key carries scopes: what it may be used for, chosen when it is issued, one permission at a time, with a checkbox for each (compose, read, download, manage templates, send for signature, the one-call flow, bulk jobs, usage, test calls), and never widened afterward — a key's permissions only ever shrink. Calling something outside its scope answers TOKEN_SCOPE_NOT_ALLOWED; issue a new, wider key rather than trying to edit the old one. - A key has a name, which is required and different from the name of every other live key of your organisation (billing-export, crm-sync); the name of a revoked key can be used again. Keys are issued only in the console, on the API keys page: never by us on your behalf. - Keys are stored only as hashes. Not even an administrator can read an issued key back — the console shows only its last four characters, to tell several open keys apart. ### The starter key A new account is handed a starter key, named hello-world, instead of a full-access one. It holds exactly what the first call needs: documents:compose, documents:read, documents:download, usage:* and isTest (the sandbox). It cannot manage templates, send for signature, run the flow or submit a bulk job, and it expires after a short time (90 days unless we have changed it). A coordinator creates a full key in one click on the API keys page of the console; nothing about an existing key changes when you do. Calling something the starter key does not hold answers TOKEN_SCOPE_NOT_ALLOWED and names the missing scope in details.missingScopes. ### Which scope each call needs A key must hold a scope of the call's group, and the specific scope for the call; the group scope (documents:*, signatures:*, bulk:*) satisfies every call of its group. A refusal lists the scope you were missing. A bulk submit needs two: bulk:submit runs the batch and the single call’s scope (documents:compose, signatures:ses or flow:*) authorises what is done in it, so a batch is never a way round a scope. With both missing the answer names the first, bulk:submit; once that is granted it names the other. | Call | Scope | | POST /api/v1/compose/documents/generate | documents:compose | | POST /api/v1/compose/documents/validate | documents:compose | | GET /api/v1/compose/documents/{id} (the PDF), /api/v1/compose/documents/{id}/download-links | documents:download | | POST or DELETE /api/v1/compose/files | documents:compose | | GET /api/v1/compose/files | documents:read | | /api/v1/compose/templates/* | documents:templates | | /api/v1/sign/* | signatures:ses | | POST /api/v1/bulk/compose | bulk:submit and documents:compose | | POST /api/v1/bulk/sign | bulk:submit and signatures:ses | | POST /api/v1/bulk/flow | bulk:submit and flow:* | | POST /api/v1/bulk/files, cancel or retry a job | bulk:submit (a retry also needs the scope of the job’s operation) | | GET /api/v1/bulk/jobs* | bulk:read | | /api/v1/flow/* | flow:* | | GET /api/v1/usage, GET /api/v1/callback-environments | none: readable by any live key | | A request with "isTest": true | isTest, on top of the above | ### Expiry notices We email the organisation's coordinators once at 30, 7 and 1 day before a key expires, with the exact date and a link to create the next one. A key whose whole life is no longer than a threshold skips it. There is no grace period: at the stated second the key stops working. ### A limit for one key Besides the organisation's requests-per-minute limit, a coordinator can give a single key its own, on the API keys page. A key over its limit is refused with RATE_LIMIT_EXCEEDED (HTTP 429, the usual Retry-After and X-RateLimit-* headers, and details.limitedBy: "token") before the request counts against the organisation, so one broken integration cannot use up the rest. The organisation's limit still applies to every key. With no limit set the key follows a platform default, which is none. ### What a key may call The suite is three products, sold separately, and an organisation may hold any combination of them: | Surface | Prefix | Also answers on | | Composer Fly — document composition | /api/v1/compose/* | /api/v1/documents/*/api/v1/templates/* | | Signer Fly — electronic signature | /api/v1/sign/* | /api/v1/signatures/* | | Document Fly — the one-call flow | /api/v1/flow/* | — | One prefix per product, since 2026-09-19. The three surfaces are the three products as they are sold and switched, so the URL now says which one you are calling. Everything under a prefix belongs to that product: /api/v1/compose/documents/generate, /api/v1/compose/templates, /api/v1/sign/envelopes. The previous paths still work and are not going away on a date we have not told you. If your integration calls /api/v1/documents/generate, it keeps working exactly as it did — same request, same response, same organisation switch governing it. The new spelling is what this guide documents and what the service hands back in its own responses (a flow's document.url, for instance); moving across is a change of string and nothing else. If we ever do retire the old prefixes, it will be announced with a date and a notice period, not discovered. A call to one that is not active answers 403 SUITE_NOT_ENABLED, names it in details.suite and lists what you do have in details.enabled. Nothing is charged for it, and no key change will fix it — it is a subscription, not a credential. A coordinator can see the same list under Settings in their own console. The switches govern doors, not capabilities. A flow composes a document internally whether or not the composition API is on for you — you are calling the flow, not the composition API. But its answer links to /api/v1/compose/documents/{id} and to the envelope's own download, so with only the flow active those links answer SUITE_NOT_ENABLED. If you need to fetch what a flow produced, you need the surface it lives on. It applies to API keys only. Your console is unaffected: templates, documents and signatures stay visible there whatever is switched off here. ## Example payloads Source: https://app.doconure.com/docs/payload-examples Five ready payloads - an invoice, a quote, a delivery note, a monthly report, an agreement - each with a minimal template that reads it: lists, a list inside a list, a part that appears only when it applies. ## Example payloads Five documents people make every day, each as the JSON you send and a minimal template that reads it. Copy the pair, render it once, then make the template yours: the shape of the data stays. Each template is HTML because it fits on the page; the same tags work in a Word or Excel template (Writing a template, Lists and repeating rows). Send any payload as data in POST /api/v1/compose/documents/generate, with your template's id in settings.templateId (Generating a document). For a template you already uploaded, the console pre-fills its own example on the template's page, under Test with JSON. ### An invoice: lines and totals One list (lines) that becomes one row each, and totals you compute and format yourself: the template places values, it does not do arithmetic. ``` { "number": "2026-001", "date": "31/10/2026", "customer": {"name": "Acme S.r.l.", "vatNumber": "IT01234567890", "address": "Via Roma 1, 20121 Milano"}, "lines": [ {"description": "Consulenza", "quantity": "2", "price": "500,00", "amount": "1.000,00"}, {"description": "Trasferta", "quantity": "1", "price": "240,00", "amount": "240,00"} ], "net": "1.240,00", "vat": "272,80", "total": "1.512,80" } ``` ```

Fattura {{ number }} del {{ date }}

{{ customer.name }} · P.IVA {{ customer.vatNumber }}
{{ customer.address }}

{% for l in lines %} {% endfor %}
DescrizioneQ.tàPrezzoImporto
{{ l.description }}{{ l.quantity }}{{ l.price }}{{ l.amount }}

Imponibile {{ net }} · IVA {{ vat }} · Totale {{ total }}

``` ### A quote: a part that appears only when it applies A discount section printed only when the payload carries one. Leave the field out, or send it empty, and the section is not in the document. ``` { "number": "PREV-2026-014", "customer": {"name": "Acme S.r.l."}, "validUntil": "30/11/2026", "items": [ {"description": "Licenza annuale", "amount": "1.200,00"}, {"description": "Formazione (1 giorno)", "amount": "600,00"} ], "discount": "10%", "total": "1.620,00" } ``` ```

Preventivo {{ number }}

Per {{ customer.name }}, valido fino al {{ validUntil }}.

    {% for i in items %}
  • {{ i.description }}: {{ i.amount }}
  • {% endfor %}
{% if discount %}

Sconto applicato: {{ discount }}

{% endif %}

Totale {{ total }}

``` ### A delivery note: a list inside a list Packages, each with its own goods: a loop inside a loop. The inner name (i) reads the current package's list. Inside an object, do not call a list items, keys or values: p.items is the object's own method, not your list. ``` { "number": "DDT-2026-087", "date": "31/10/2026", "recipient": {"name": "Acme S.r.l.", "address": "Via Roma 1, 20121 Milano"}, "packages": [ {"label": "Collo 1", "goods": [{"code": "A-100", "description": "Staffa", "quantity": "20"}, {"code": "A-200", "description": "Vite M6", "quantity": "200"}]}, {"label": "Collo 2", "goods": [{"code": "B-310", "description": "Pannello", "quantity": "4"}]} ] } ``` ```

Documento di trasporto {{ number }} del {{ date }}

Destinatario: {{ recipient.name }}, {{ recipient.address }}

{% for p in packages %}

{{ p.label }}

{% for i in p.goods %}{% endfor %}
{{ i.code }}{{ i.description }}{{ i.quantity }}
{% endfor %} ``` ### A monthly report: figures and a table A few headline figures and a table of rows. A long table renders faster with a fixed layout: A long table: fix its layout. ``` { "month": "Ottobre 2026", "company": {"name": "Acme S.r.l."}, "figures": {"orders": "128", "revenue": "48.200,00", "returns": "3"}, "rows": [ {"day": "01/10/2026", "orders": "4", "revenue": "1.520,00"}, {"day": "02/10/2026", "orders": "6", "revenue": "2.310,00"} ] } ``` ```

{{ company.name }}: {{ month }}

Ordini {{ figures.orders }} · Fatturato {{ figures.revenue }} · Resi {{ figures.returns }}

{% for r in rows %}{% endfor %}
GiornoOrdiniFatturato
{{ r.day }}{{ r.orders }}{{ r.revenue }}
``` ### An agreement: parties and numbered clauses Two parties and a list of clauses, ready to be sent for signature: compose it and sign it in one call with Document Fly, placing the signatures with the fields or the tags of the signing guide. ``` { "title": "Contratto di servizio", "date": "31/10/2026", "provider": {"name": "Acme S.r.l.", "representative": "Anna Rossi"}, "client": {"name": "Acme Servizi S.p.A.", "representative": "Marco Bianchi"}, "clauses": [ {"title": "Oggetto", "text": "Il fornitore presta il servizio descritto nell'allegato A."}, {"title": "Durata", "text": "Dodici mesi dalla firma, rinnovabili per iscritto."}, {"title": "Corrispettivo", "text": "1.200,00 euro annui, IVA esclusa, fatturati anticipatamente."} ] } ``` ```

{{ title }}

Tra {{ provider.name }}, rappresentata da {{ provider.representative }}, e {{ client.name }}, rappresentata da {{ client.representative }}, il {{ date }}.

    {% for c in clauses %}
  1. {{ c.title }}. {{ c.text }}
  2. {% endfor %}
``` ### Your own payload Any shape your system already has will do: a template reads nested objects with dots (customer.address) and lists with a loop. Names are matched without regard to case. A field the template reads and the payload lacks renders empty and is reported, as TEMPLATE_FIELDS_MISSING: see Missing and extra fields. To check a payload without generating anything, use Validate before you send. ## How do I let an AI agent write the integration? Source: https://app.doconure.com/docs/ai Point Claude Code, Codex or Cursor at /llms-full.txt and let it write the integration. The five rules an agent should follow, and why the generated prompt never contains your API key. ## How do I let an AI agent write the integration? This API is meant to be integrated by Claude Code, Codex, Cursor or whatever else you have open. Two files exist for that, and a button that writes the prompt for you. ### The two files | File | What is in it | | /llms.txt | A short index: what this service is, and one line per documentation page. Cheap to fetch and enough to decide what to read next. | | /llms-full.txt | Everything, in one fetch: every endpoint, every setting, the response envelope, every outcome code with its number and whether it is retryable, then the whole guide as text. About 100 KB. | Both are generated from the running service. The settings come out of the request model, the outcome numbers out of the error catalogue, the response shape out of the code that builds it. They cannot describe a version of the API that does not exist, which is the whole reason to point an agent at them rather than let it infer the contract from an example. ### The button Your console has an Integrate with AI button on each template. It writes a prompt that already names your template id, the fields that template reads, and a payload of the right shape — the three things an agent cannot look up — and points it at /llms-full.txt for everything it can. The prompt never contains your API key, on purpose. It says to read the key from the DOCONURE_API_KEY environment variable. A prompt is pasted into a chat transcript, stored by whoever runs the model, and often ends up in a file that gets committed; a key that reaches all three has been published. Reading it from the environment is also the integration you would want anyway, so there is nothing to trade off. ### Rules for an agent If you are writing the prompt yourself, these are the five that matter. They are the ones that are cheap to state and expensive to get wrong. - Read /llms-full.txt before writing any code. Do not infer the request shape from an example. Unknown keys anywhere in the body outside data are refused, not ignored, so a plausible-looking invented setting fails the whole call (data is yours, and is free-form). - Read the API key from the environment (DOCONURE_API_KEY). Never write it into a source file, a committed config, or a log line. - Branch on code, never on message. Every failure carries a stable code and a four-digit number; the message is a hint for a human reading a log and may be reworded at any time. - Retry only when the body says "retryable": true. A TEMPLATE_DATA_MISMATCH retried a hundred times fails a hundred times, and each attempt is billed as work done. - Surface output.warnings. A call can succeed having done less than it was asked. TEMPLATE_FIELDS_MISSING means the PDF was produced with blanks in it and names the fields; swallowing that warning is how a customer finds out from their own customer. Two more that are about this API in particular: generation is synchronous, so use a client timeout of at least 120 seconds; and send "responseFormat": "json" inside settings if you want the ids, the warnings and the document in one JSON body rather than raw PDF bytes with the identifiers in headers. ### Model Context Protocol An official MCP server is planned and not built yet, so that this page does not promise one. When it exists it will expose the operations an agent actually needs — list templates, fetch a template's expected fields, validate a payload before spending a credit on it, generate a document, read usage — and it will be announced here and in /llms.txt. Until then, /llms-full.txt plus an agent that can make HTTP requests is the whole integration, and it is enough: there is one endpoint to learn and no SDK to install. Next: First document · Outcomes and error codes · Generating a document ## How do I compose and send for signature in one call? Source: https://app.doconure.com/docs/howto-flow POST /api/v1/flow composes the document and puts it out for signature in a single request, so you hold no state between two. ## How do I compose and send for signature in one call? Use /api/v1/flow instead of /api/v1/compose/documents/generate whenever the document is going out for signature the moment it exists — one request, one round trip, and no window where you are holding a document that has not been sent yet: ``` curl -X POST https://your-host/api/v1/flow \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-4471" \ -d '{ "data": { "customer": { "name": "Acme Srl" } }, "settings": { "templateId": "contract-standard-8bc99b" }, "signature": { "subject": "Service agreement", "signers": { "A": { "email": "anna@acme.example", "fullName": "Anna Rossi", "fields": [{ "kind": "signature", "page": 1, "x": 0.1, "y": 0.78, "width": 0.32, "height": 0.09 }] } } } }' ``` Where the signature goes. The usual way is a tag in the template, {{ signature.A }}, written like any other field: signer A then needs no fields and signs where the tag is. The alternative is fields, as here: x and y are fractions of the page from the top-left corner (0.1 = a tenth of the way across). How to read them off a PDF, and everything else about placing a signature: Where the signature goes on the page. Always send the Idempotency-Key — this call charges twice and emails a real person, and a retried request without one does both again. Full field list and what comes back: Compose and sign, in one call. ## How do I ask for signatures in a specific order? Source: https://app.doconure.com/docs/howto-order Sequential signing: each signer is notified only once the person before them has finished. One flag and a number per signer. ## How do I ask for signatures in a specific order? Set hasSigningOrder: true and give each signer a number. Two signers who share a number sign as one group — the next number is not told anything until every signer in the group before it is done: ``` "signers": { "A": { "email": "reviewer@acme.example", "fullName": "First reviewer", "order": 1, "fields": [ /* ... */ ] }, "B": { "email": "approver@acme.example", "fullName": "Final approver", "order": 2, "fields": [ /* ... */ ] } }, "settings": { "documentId": "...", "subject": "...", "hasSigningOrder": true } ``` Want the two of them to sign in parallel instead, as a pair, before a third person? Give both of them "order": 1 and the third person "order": 2. Leaving hasSigningOrder at its default false is the same idea as giving everyone the same number: no order, everybody notified together — and in that case order must not be set at all (it would never be honoured, so the request is refused rather than silently ignored). ### Does the whole group have to sign, or just one of them? Each signer also takes a groupMode, and it means nothing on its own — it describes the group a shared order forms, so every signer sharing a number must agree on it (or leave it unset and let somebody else in the group decide): | Value | Means | | "AND" (default) | Everybody sharing this order must sign before the next group is told anything — the behaviour this product always had, and what you get by leaving groupMode out. | | "OR" | The first signature in the group is enough. Everybody else still pending in it is marked "skipped" in the response, their link dies immediately, and the next group is told right away. A skipped link opens a page that says the link is no longer valid, and nothing can be done with it; a request already in flight from that page is refused with RECIPIENT_NOT_REQUIRED. | The normal shape — two named approvers, either one will do — is two groups of one signer each, one per group: ``` "signers": { "A": { "email": "finance-a@acme.example", "fullName": "Approver A", "order": 1, "groupMode": "OR", "fields": [ /* ... */ ] }, "B": { "email": "finance-b@acme.example", "fullName": "Approver B", "order": 1, "groupMode": "OR", "fields": [ /* ... */ ] } }, "settings": { "hasSigningOrder": true } ``` Both share "order": 1 — one group — and both say "OR": whichever of the two signs first satisfies the group, the other's link dies, and there is no second group to wait on. Put the two approvers on different order values instead (still one signer each) and you have two sequential groups, not one OR'd pair — order is what makes a group, groupMode only says how it behaves. A cap on the shape, not only the total. MAX_RECIPIENTS (20) already bounds every envelope, but an ordered one is bounded two more ways at once: at most 5 distinct order values (groups) and at most 4 signers sharing any one of them. Both are the same 5×4 = 20 ceiling seen from the shape rather than the total, and going over either answers a plain 422 naming which limit and which value — settings.hasSigningOrder being false skips both checks, since there is no group to bound. Full settings reference: Signing on its own. ## How do I keep someone informed without asking them to sign? Source: https://app.doconure.com/docs/howto-cc-bcc Cc and bcc on an envelope, and choosing whether they hear about every step or only about the completed document. ## How do I keep someone informed without asking them to sign? Add their address to cc or bcc in settings — they get a plain notice, never a signing link: ``` "settings": { "documentId": "...", "subject": "...", "cc": ["sales@acme.example"], "ccNotifyOn": "completion" } ``` ccNotifyOn/bccNotifyOn is "none" (default — nobody hears anything), "completion" (one email, once everyone has signed, with the signed PDF attached), or "everything" (also the initial send and every reminder). Each address gets a mail of its own, so nobody on either list sees the others: cc and bcc behave the same. Full reference: Cc and bcc on an envelope. ## How do I choose how a signer proves it's them? Source: https://app.doconure.com/docs/howto-validation Slide-to-confirm, a code by email, or a code by SMS. What each one proves, what each one costs, and when to reach for which. ## How do I choose how a signer proves it's them? Set validationMode on that signer. The default, "sign", is the fastest: a mark plus an explicit slide-to-confirm gesture, no code at all. "email" adds a one-time code sent to that signer's own address before their mark counts: ``` "signers": { "A": { "email": "...", "fullName": "...", "validationMode": "email", "fields": [ /* ... */ ] } } ``` "sms" works the same way as "email" — request a code, submit it — with the code sent by text to the signer's phone. It requires a "phone" field on that signer (free text, any shape). Where the code cannot be delivered by SMS (no gateway configured, or a country the sender is not registered in) the envelope is refused at creation with SMS_VALIDATION_UNAVAILABLE, and nothing is created or sent. An SMS code is something your organisation buys. We switch it on for you; until then a request that asks for "sms" (or an organisation default of sms) is refused with FEATURE_NOT_ENABLED and details.feature set to smsValidation, nothing is created, and nothing is quietly sent by email instead. If the service is withdrawn after an envelope was sent, its signers still get their code, by email: nothing is bought that your organisation no longer holds. Each SMS costs two credits, charged to your organisation when the SMS gateway accepts the message: a signer who asks for a new code gets a new message, charged again (at most eight codes per signer). A message that does not leave is not charged. The charge never stops the signer: the code is sent even if your balance has run out, and the next credit you add repays it first. output.credits.used of the envelope includes these charges as they happen. With a plan that has an SMS counter, the code is taken from the plan first, and past it from the credit. Every outcome an envelope call can produce: Outcomes and error codes. Replacing a signer who confirms by SMS, or a signer passing their own turn on, needs the new person's mobile number: the old number is never kept and the code never goes to it (Replacing a signer). Signature levels (SES/AES/QES) are a separate axis from validation mode — see Signature levels: SES, AES, QES. ## How do I remind a signer, or resend a lost link? Source: https://app.doconure.com/docs/howto-remind Automatic reminders on a schedule you set, and reissuing one signer's link when it never arrived. Neither charges again. ## How do I remind a signer, or resend a lost link? A signer says they never got the email, or lost it. You do not create a new signature request and you do not pay for one: resend the same request to that signer. The envelope, its documents, its fields and its signing order stay exactly as they were, and nothing is charged (the signature was billed when you sent the envelope). ### Resend to one signer Take the recipient's id from a GET on the envelope (recipients[].id) and call: ``` curl -X POST https://your-host/api/v1/sign/envelopes/ENVELOPE_ID/recipients/RECIPIENT_ID/resend \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Idempotency-Key: resend-ENVELOPE_ID-RECIPIENT_ID-1" ``` The answer is the envelope, in the usual output.sign shape. The signer gets the invitation again, in the envelope's language, with a subject that says it is a resend; the console has the same button on the signer's row. | What you should know | Detail | | The link is a new one | The previous link stops working the moment the new one is issued. A signer who opens the old email is told the signing link is not valid; the new email is the one to use, so say so if you tell them by phone. There is never more than one live link per signer. | | Why not the same email, byte for byte | We keep only a hash of each link, never the link: nobody, including us, can read back a link that was sent. So “send the same message again” is not possible without storing live credentials; a fresh link is what a resend is, and for the signer it reads as the same request. | | Who can be resent to | Only a signer who can act now: the envelope must be sent or in_progress, and the signer must not have signed, declined or been skipped, and must be in the current group (in a signing order, a later signer has no link yet). Otherwise the call is refused with a stable code (ENVELOPE_NOT_EDITABLE, RECIPIENT_ALREADY_SIGNED, SIGNING_OUT_OF_TURN, RECIPIENT_NOT_REQUIRED). | | Codes and reminders restart | A signer who was locked out by wrong codes gets a clean count, and the automatic reminder clock starts again. | | Cost | No credit for the signature. An SMS code the signer asks for afterwards costs what an SMS costs. | | Retries | Send an Idempotency-Key: the same key returns the first answer and does not mail the signer a second time. | If the address was wrong, do not resend: replace the signer (POST .../recipients/RECIPIENT_ID/replace) with the right one, which also revokes the old link. ### Automatic reminders To nudge without anybody asking, set reminderFrequencyHours in settings — above zero, whoever must currently sign gets a fresh link every that many hours, counted from when they were told: ``` "settings": { "documentId": "...", "subject": "...", "reminderFrequencyHours": 48 } ``` For one reminder to everybody who still has to sign, right now, use the Remind button on the envelope in the console; it ignores the cadence and reissues each link, as a resend does. Either way the previous link dies when the new one is issued, and none of it is a second charge. ### When the link has expired, or was revoked or replaced The invitation and every reminder say the day, time and time zone the link stops working, in your organisation's locale. A link that stopped working before it was used opens a page that says why — the request expired, was revoked by the sender, or the signer was replaced — in the request's language; nothing can be done from it. When a request expires or is revoked, every signer who had been invited and had not finished also receives a message that says so. While the request is still open, a resend (above) gives the signer a fresh link. Once it has expired it cannot be reopened: send a new request. To give a request longer, set expirationDate when you create it (at most a year), or change your organisation's default expiry in Settings. ## How do I know when something finished? Source: https://app.doconure.com/docs/howto-poll Poll output.status or take a signed webhook callback. What each costs you in calls, and why you should not branch on a status string. ## How do I know when something finished? Two ways, and you can use either. Polling is below and always works. A callback is usually better if you can accept an inbound request: set callbackUrl (in signature for a flow, in settings for an envelope) and we tell you instead, which matters most on a platform with a daily call budget — an envelope left open three days, polled every five minutes, is about 860 calls to learn nothing 859 times. If your receiver wants us to authenticate (a token, an API key, OAuth2 as Salesforce does), set up a callback environment and send {"env": "prod", "onSuccess": "/hooks/ok"}. To hear when a signer is replaced as well, add the optional onEvent address: it receives recipient.replaced, while onSuccess and onError keep saying only how the request ended (details). To poll, ask GET /api/v1/flow/{flowId} (or GET /api/v1/sign/envelopes/{id} if you used the two-step path) and branch on output.status, not on the specific status string: ``` curl https://your-host/api/v1/flow/FLOW_ID \ -H "Authorization: Bearer YOUR_API_KEY" { "output": { "status": "pending", "flow": { "status": "awaiting_signature", "closed": false }, "...": "..." } } ``` "status": "pending" means keep asking. "succeeded" and "failed" both mean stop: between them they cover every way a flow stops changing — signed, declined, expired, cancelled — so code that checks output.status keeps working the day a new status is added, instead of looping forever on it. When you need to tell the three ways of failing apart, output.flow.status is still there. The same two words work on an envelope: GET /api/v1/sign/envelopes/{id} answers the identical shape, with output.sign in place of output.flow. One poller, either path. ### See, replay and try your webhooks In the console, Webhooks lists every notification we sent to your system — the event, the URL, our attempts, what your server answered — and the exact body. What you can do there: - Send again. Queues a new delivery with the same stored body: the same bytes and the same event.id, so a receiver that deduplicates recognises it and one that does not gets exactly what it was sent the first time. It goes through the ordinary schedule and retries; the original keeps its history. - Send all abandoned again. After your server was down longer than our retries (44 hours), one press queues every abandoned event again, oldest first, each exactly as Send again would; an event already sent again is never queued twice, and at most 200 go per press. - Send a sample event. A made-up event in the real shape, signed with your secret, sent now through the same code as a real one (https only, public addresses only, no redirects). It says event.simulated: true inside the signed body and is never retried, so you can write your handler before you have a real envelope. - All of them are audited, and rate-limited per organisation. Full status table: Compose and sign, in one call. ## How do I make our callbacks authenticate at my system? Source: https://app.doconure.com/docs/howto-callback-environments Environments: name a callback base URL and how we authenticate there (Bearer token, API key or OAuth2 client credentials, as Salesforce wants), then send only the environment and the path in each call. ## How do I make our callbacks authenticate at my system? When a signature closes we POST to your system (How do I know when something finished?). Every callback is signed with our HMAC (X-Signer-Signature), so you can tell it is ours. But many receivers also want the caller to authenticate: an OAuth token, an API key, a fixed Bearer token. Salesforce Apex REST on your org's domain is the common case. A callback environment is how you give us that, once, in the console. ### Set one up In the console, open Environments in the left menu and add one: | Field | What it is | | Sigla | A short name a call uses: lowercase letters, digits and _, at most 16, starting with a letter. prod, uat4, dev_5. It never changes. | | Base URL | https, optionally a path prefix: https://myorg.my.salesforce.com/services/apexrest. No user name, no query string. The host must be a public address: that is checked when a callback is sent, and a private one abandons the callback with the reason in the Webhooks log. | | Authentication | One of the four types below, with its credentials. | | Type | What we send | | No authentication | Nothing but our HMAC. Allowed, at your risk: anybody who learns the URL can post to it, and checking the signature is then your only proof. The console warns you. | | Bearer token | Authorization: Bearer | | API key in a header | : , for example X-API-Key | | OAuth2 client credentials | Authorization: Bearer , a token we fetch from your token URL with your client id and secret (in the form body or as HTTP Basic), with an optional scope, audience and extra form parameters | The secret (the token, the key or the client secret) is stored encrypted and never shown again; to change it, type a new one. The first environment you add is the default. Adding a second never changes the default: only you do, with Make default. ### Use it in a call Name the environment and send only the end of the URL. The paths are appended to the base URL: ``` "callbackUrl": { "env": "uat4", "onSuccess": "/doconure/ok", "onError": "/doconure/ko" } ``` The same block works in POST /api/v1/sign/envelopes (in settings), in POST /api/v1/flow (in signature) and as a bulk job's completion webhook. Without env, paths go to your default environment. A call that sends no callback at all uses the default paths of the default environment (set on the environment's own form, On success and On failure). A full https://... URL still works as it always did, with no environment and no authentication. #### What is refused, at the call - An env you do not have, or paths with no default environment: CALLBACK_ENVIRONMENT_UNKNOWN (1625). An env that is not a valid sigla at all (upper case, a dash, more than 16 characters) is refused by the request's shape, INVALID_REQUEST_BODY (1101), and so is a URL whose scheme is neither http nor https. An http:// URL is accepted at the call but never called: a callback goes only to https, so use https. - A path that could change the host (a scheme, //, @, .., a backslash, a #), a full URL together with env, or a path mixed with a full URL: CALLBACK_PATH_INVALID (1626). This is what keeps your credentials going only to your own host. Both are refused before anything is created or charged: you find out on the call, not two days later in the delivery log. There is no type in the call: the type of authentication belongs to the environment. #### Which siglas can I send? Ask before you are refused: GET /api/v1/callback-environments lists your organisation's environments, the default first, readable by any live key. ``` { "environments": [ { "env": "uat4", "isDefault": true, "authType": "bearer", "defaultSuccessPath": "/doconure/ok", "defaultErrorPath": "/doconure/ko" }, { "env": "prod", "isDefault": false, "authType": "oauth2_client_credentials", "defaultSuccessPath": null, "defaultErrorPath": null } ], "count": 2 } ``` Read-only, and it says nothing a credential could leak through: no host, no header name, no token or client secret, only the sigla, which is the default, the kind of authentication and the default paths. Creating or changing an environment stays in the console. To be told when a signer is replaced, add onEvent beside the other two paths (see Being told when a signer is replaced). ### How a callback is delivered - The environment is read when the callback is sent, so changing its base URL or rotating its secret also applies to callbacks already waiting. - An OAuth2 token is fetched when it is first needed, kept until it expires, and fetched again once if your receiver answers 401. A token endpoint that is down or answers an error is retried on the ordinary schedule; your receiver is never called without a token. - The token URL is held to the same rules as the callback: https only, public addresses only, redirects never followed. A token URL on a private address abandons the callback (it is your configuration to fix, not something time will). - The default success and failure paths belong to the default environment. Without a default environment there are no default paths, and a call that sends only a path is refused with 1625. - Our HMAC and the X-Signer-* headers travel on every callback, plus X-Signer-Environment with the sigla. - An environment that open envelopes, running batches or waiting callbacks still name cannot be deleted (CALLBACK_ENVIRONMENT_IN_USE). The Webhooks page shows every delivery with its environment, and can send a sample event through one, so you can check the authentication before a real envelope closes. ### Salesforce, as an example In your org, create an External Client App (or a connected app) with the client credentials flow enabled and a run-as user, and expose an Apex REST class at, say, /doconure/ok. Then add an environment: ``` Sigla prod Base URL https://myorg.my.salesforce.com/services/apexrest Authentication OAuth2 client credentials Token URL https://myorg.my.salesforce.com/services/oauth2/token Client id (the consumer key) Secret (the consumer secret) ``` and send "callbackUrl": {"onSuccess": "/doconure/ok", "onError": "/doconure/ko"}. No Site, no guest user, no relay in between. The details of the app depend on your org's release and settings: check them in Salesforce's own setup. The callback body and its signature: How do I know when something finished? · Salesforce (Apex) ## How do I put my brand on what signers see? Source: https://app.doconure.com/docs/howto-branding Branding in the console: your logo and colours, your own wording of the mails a signer receives (with placeholders, a test mail and the built-in text to start from) and a few lines on the signing pages, all checked before it is saved. ## How do I put my brand on what signers see? A signer meets your organisation in three places: the mails they receive, the signing page they open from them, and the page they land on afterwards. In the console, Branding (in the left menu, for coordinators of the organisation) changes all three, in one page with three tabs. It is meant to be quick: a logo, three colours and your own words, nothing to design. Everything is checked before it is saved, and the answer names each problem, so a mail that would end in a spam folder never goes out. ### The three tabs | Tab | What you change | | Brand | Your logo (PNG or JPEG), the header, button and footer colours, the footer text in Italian and in English, the sender name and the address replies go to, and two switches: brand the signer emails, brand the signing pages. A preview shows a sample mail and a sample signing page, on a computer and on a phone. | | Emails | The wording of each mail a signer receives, in Italian and in English: the invitation, the reminder, the withdrawal, the expiry, the signed copy and the copy notice. Each opens with our own text already filled in, so you edit instead of writing from scratch. | | Signing pages | Your logo and colours on the pages a signer meets, and four short lines of your own: an introduction under the title, a message after signing, a message after declining and a message on a link that no longer works. | What you never change: the structure of a mail, the closing notice (how to report a document the person was not expecting), the one-time code mail, the notices we send to you, and on the signing page the legal and consent lines, the code step and the signature controls. And you never write HTML: a mail is plain text with placeholders and **bold**. ### Writing a mail Open Emails, choose a mail and a language. The subject and the text are already there. Change them, press Preview to see them as a signer will, and Save. A language you leave alone keeps using our text. A change applies to mails written from then on; a mail already sent keeps the words it was sent with. - Send me a test mails the wording you are editing, with sample values, to your own address, marked [TEST]. Nothing is saved, and you can send a few a minute. - Restore the default goes back to our text for that mail and language. A text you leave identical to ours counts as the default. - Each mail shows whether it is Default or Customised, per language. #### Placeholders A placeholder is a name between double braces, such as {{ full_name }}. It is swapped for its value by plain text substitution when the mail is written. Only the names below exist: any other name is refused, and nothing you type is ever run as a template. A line whose placeholder has no value (no deadline, no note from the sender) is left out of the mail. | Placeholder | Becomes | Where it can be used | | {{ full_name }} | The signer's name | Every signer mail | | {{ subject }} | The document's name (a test request is marked [TEST]) | Every mail, in the subject or the text | | {{ message }} | The sender's note to the signer | Invitation, reminder (text only) | | {{ link }} | The signing link, shown as a button | Invitation, reminder (text only, required) | | {{ expires_at }} | When the link stops working, in your organisation's date format and zone | Invitation, reminder (text only) | | {{ code_note }} | The sentence about the confirmation code, in the mail's language; empty when the signer needs no code | Invitation, reminder (text only) | | {{ intro }} | Your introduction line (set on the Emails tab) | Invitation, reminder (text only) | | {{ replaced_note }} | The sentence saying somebody else has been asked to sign instead | Withdrawal (text only) | | {{ stage_label }} | What the copy notice is about: sent for signature, reminder sent, or signed | Copy notice | | {{ stage_note }} | The sentence the copy notice is about, with the document's name | Copy notice (text only, required) | | {{ sender_name }} | The sender name you set under Brand (your organisation's name if empty) | Every mail | | {{ organisation }} | Your organisation's name | Every mail | The signing link must be on a line of its own, or written Sign now: {{ link }} (a short label, a colon, the link), with at most one more line under it: that is what becomes the button, with the address printed underneath for a mail program that shows no buttons. #### What is checked, and why Every check runs before saving. If any fails, nothing is saved and the page lists every problem, field by field. | Check | The rule | | Placeholders | Only the names above; the required ones present (the invitation and the reminder need the link, on its own line; every mail but the copy notice must name the document in the subject or the text); no {% %} or other template syntax. | | Text only | No HTML tags, no Markdown links, no web address of any kind (the one link is the placeholder), no email address or phone number in a mail text. The footer text on the Brand tab is the place for a contact address, a number or your website. | | Length | Subject 5 to 90 characters, text 40 to 3,000, no line over 400 characters; each signing-page line up to 400, the footer up to 600. | | Tone | At most 2 exclamation marks in the subject and 5 in the text, no run of the same punctuation (!!!, ----), no more than a few words in capitals, and none of the wording that sends a mail to the junk folder (in Italian and in English: free money, click here, clicca qui, hai vinto, and the like). | | Never asked | A signing mail never mentions passwords, PINs, bank or card details: it is what a phishing mail asks for, and it would make your real mails look like one. | | Hidden characters | No zero-width or direction-changing characters, no link shorteners. | | Colours | Six hex digits like #153563, and the text on the colour must reach a contrast of 4.5 to 1 (WCAG 2.2 AA). A colour that cannot is refused with the nearest one that can. The button colour is also used on the signing page, where it must read as text on white; a lighter button colour still works in the mail, and the page keeps our green. | | Logo | PNG or JPEG, up to 512 KB, each side between 32 and 2,000 pixels. SVG is refused: an SVG file can carry script. | ### The signing pages On the Signing pages tab, switch the pages on and write up to four lines, each in Italian and in English (a page uses the text of its language, else the other one). The pages then wear your logo, your header colour behind our name at the top and your button colour on the buttons. It is a small stylesheet built from your validated colours, never your own CSS, and the page works without script. The lines are plain text: no HTML, no placeholders, the same checks as above. ### On a phone The Branding page works on a phone, with every control a finger can reach, and each preview has a phone-width version beside the computer one. The mails and the signing pages are drawn to fit a small screen whatever you write. ### Who can change it Only the organisation's coordinators. A colleague with the user role does not see Branding at all. Each save is written to the audit trail with who and what (the mail and the language, a version number), never the words. More on the mails: The mails your signers receive, in your look · How do I remind a signer? ## How do I check a signed file later? Source: https://app.doconure.com/docs/howto-verify Verify a signed PDF months afterwards: the hash, the evidence bundle, and what an auditor would be shown. ## How do I check a signed file later? Anyone can confirm a signed PDF is exactly what this service produced, without an API key, by uploading it at /verify — the page recomputes its SHA-256 and reports a match or "No match", in Italian or English (?lang=it or ?lang=en; by default the language of the signed request). To do the same thing from code, compute the hash yourself and compare it to signedSha256 from a GET on the envelope: ``` curl -F "document=@signed.pdf" https://your-host/verify ``` ### The same check, for a machine No key either, and rate-limited per address. Ask for JSON: ``` GET /verify/{sha256}.json POST /verify.json (multipart, part "document") 200 OK { "schema": 1, "queriedSha256": "ab12...", "found": true, "integrity": { "result": "matches_a_signed_file", "algorithm": "sha256" }, "signature": { "level": "SES", "legalQualification": "simple_electronic_signature", "status": "completed", "completedAt": "...", "signerCount": 2, "signers": [{ "fullName": "Anna Rossi", "signedAt": "..." }], "isTest": false }, "seal": { "requested": true, "padesProfile": "B-T", "applied": true }, "timestamp": { "requested": true, "applied": true }, "evidence": { "chain": { "intact": true, "events": 14 }, "purged": false, "retention": "10 years from the envelope closing, unless the organisation chose a shorter period" } } ``` It is deliberately never collapsed into one valid boolean. A matching hash says the file is exactly what was signed here; it does not say the seal was qualified (qualified is never asserted: no certificate in this deployment is declared qualified), that a timestamp is still verifiable, or that the signer was who they claimed. Each fact is its own field with its own words, and found: false carries the same reasons the page gives. An envelope whose evidence has been purged still answers from its recorded hash and says evidence.purged: true. A test envelope says isTest: true. A single changed byte — a re-saved PDF, a copy run through another tool — is enough for this to stop matching. That is the point: it is tamper-evidence, not a checksum for its own sake. Where the fingerprint comes from: What the signer sees. ## Generating a document Source: https://app.doconure.com/docs/generate The full reference for POST /api/v1/compose/documents/generate: every setting, PDF/A and PDF/UA, watermarks, passwords, and the JSON response envelope. ## 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. | Field | Required | Default | Meaning | | data | yes | — | Free-form object. Its shape must match the template's placeholders — see Writing a template. | | settings.templateId | yes | — | Must belong to the tenant whose key you used. | | settings.templateVersion | no | highest active version | Pin a specific version number. | | settings.pdfA | no | "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.compressionLevel | no | "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.attachments | no | [] | Up to 5 fileIds of stored PDFs to append after the generated document, in order. See Files, charts and download links. | | settings.colorMode | no | "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.password | no | none | Sets 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.pdfUA | no | false | PDF/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.filename | no | "{templateId}_{unixTimestamp}.pdf" | Output filename. Letters, digits, spaces, ., _, - only; .pdf is appended if you omit it. | | settings.metadata | no | [] | 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.enable | no | "off" | "off" | "on". settings.watermark.text is required (≤60 characters) when "on" — stamped diagonally across every page. | | settings.dataless | no | false | Compose 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.responseFormat | no | "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.queueIfBusy | no | false | When 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.locale | no | "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 happens | When | | You get the first answer, byte for byte | Same 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 all | The 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. | Key | Meaning | | 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.input | The 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.credits | What the call cost and what you have left — see Credits in every answer. | | output.warnings | The 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.error | null 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. | | settings | What 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 send | What happens | | "queueIfBusy": true in settings | Real time if there is room (the PDF, as usual). If not, the document is queued: 202 Accepted. | | The header Prefer: respond-async | Queued directly, without trying real time: 202 Accepted. For work nobody is waiting on. | | Neither | Today'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: | status | Meaning | | queued, processing | Waiting, or being made. output.status is pending. | | succeeded | Made: documentId and downloadUrl (GET /api/v1/compose/documents/{documentId}), pageCount, sha256. | | expired | Not made within 15 minutes: nothing is charged, the credit held for it is back. | | failed | Not 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. | Header | Meaning | | X-Dataless | true 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-Code | Every 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-Left | What this call settled and what is left — on every generation, dataless or not. See Credits in every answer. | | X-Document-Id | Empty. There is nothing to fetch. | | X-Generation-Job-Id | Still 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. ## Validate before you send Source: https://app.doconure.com/docs/validate POST /api/v1/compose/documents/validate runs every check a generate call would run, with the page count and an estimate of the credits it would cost, and charges nothing. ## Validate before you send Ask what a generate call would find, before you make it. The dry run applies every check the real call applies, tells you how many pages the document will have and what it is expected to cost, and charges nothing, reserves nothing and stores nothing. ### The call ``` POST /api/v1/compose/documents/validate Authorization: Bearer { "data": { "number": "2026-000147" }, "settings": { "templateId": "invoice-standard-8bc99b" }, "estimatePages": true, "plannedExtras": ["signature"] } ``` The body is the body of generate, plus two optional keys. It needs the same token scope (documents:compose), the same settings rules (isTest needs its scope), and it takes an optional Idempotency-Key like every other POST: a replay within 24 hours returns the first answer, balance included, so use a fresh key for a fresh estimate. Nothing is charged either way. - estimatePages (default true): render the document once, for this call only, to count its pages. It is never stored or charged and it queues behind production work. Send false for a cheaper answer without pages.estimate. - plannedExtras: extras you will add after the document. Today ["signature"], so its cost appears in the estimate. ### The answer The call itself succeeded (output.status is succeeded); the verdict is in output.compose.validation. | Field | Meaning | | ok | No blocking problem: generate would not be refused for what was checked here. | | errors | What would refuse the call, shaped like a real error (code, number, message, details): a missing field in a strict template, a payload that fails a reject JSON Schema, a stored file that does not exist, a limit exceeded, a seal your organisation has not bought. | | warnings | What the real call would warn about, shaped like output.warnings. | | templateErrors, templateWarnings | The template diagnostics, as sentences: the same engine as the console’s Validate button and the upload (unknown formatting codes, charts that cannot be drawn, conditions the engine cannot explain). | | missingFields, unusedFields | Read by the template and absent from the payload; sent and never read. | | payloadSchema | The template’s JSON Schema verdict: mode, checked, valid, and the first violations (path, keyword, message; never a value). | | pages | estimate (the real page count of a render made for this call, appended PDFs included), method (rendered, not_requested or unavailable when the platform was too busy to spare a render), limit. | | files | The stored files the request names, checked to exist and to be the right kind. | | estimatedCredits | See below. | | charged, stored | Always 0 and false. | ### What the credit estimate promises The estimate is a deterministic model (per-operation-rate/v1), not a guess: it is the same price function the real reservation uses, at your organisation’s rates. ``` "estimatedCredits": { "model": "per-operation-rate/v1", "certainty": "dependsOn", "credits": 5, "maximum": 15, "dependsOn": [ {"item": "signature", "credits": 10, "condition": "charged when the envelope is sent (Signer Fly or Document Fly)"} ], "testItems": 0, "chargedOn": "delivery", "balance": {"availableCredits": 1247, "sufficient": true}, "plan": { "subscriptionId": "6c1f...", "planCode": "PIONR026", "takes": [{"item": "document", "counter": "calls", "units": 1}, {"item": "signature", "counter": "calls", "units": 0}], "remaining": {"calls": 4210}, "sufficient": true, "whenShort": "refused", "endsAt": "2026-12-31T23:00:00+00:00" } } ``` - credits is the document’s rate: flat, whatever the format or the number of pages. It is charged when the document is delivered and refunded on any failure. - certainty is exact when nothing else can be charged and dependsOn when something may be: a qualified seal or timestamp (only if applied) or the extras you declared. maximum is the most the whole thing can cost, and sufficient compares it with what you can still draw. - plan is null without a plan. With one, it says what each item takes from which counter of the plan (takes; on a plan counted in calls a signature planned after its document rides on the same call, units 0), what is left, whether it is enough, and what happens when it is not (whenShort: credit if you turned paying with credit on, block for a plan that never blocks, otherwise refused with SUBSCRIPTION_ALLOWANCE_EXHAUSTED). An item the plan covers takes no credit; credits and maximum stay the credit price. - An isTest call is 0 credits and one test item. - It is true for today’s rates. A later change of rate, or an envelope’s own fees beyond the extras you named, are outside it. There is no image preview. A preview would be a second render of every page to a raster, the cost of a real generation for something you cannot send. The page count is what you need before sending, and it is in the answer. ## Files, charts and download links Source: https://app.doconure.com/docs/files-and-links Upload an image or a PDF once and use it by fileId, append PDFs to a document, compress the result, and hand a document to someone with no key through a signed, expiring, revocable link. ## Files, charts and download links Upload an image or a PDF once and refer to it by id. Put a chart in a document from numbers. Make the result smaller. Hand it to someone who has no key, with an address that expires and can be revoked. ### Stored files | Call | What | | POST /api/v1/compose/files | Upload a file and get a fileId. Send multipart/form-data with a part named file, or JSON {"fileBase64": "...", "fileName": "..."}, or the raw bytes with any other content type. Needs documents:compose. | | GET /api/v1/compose/files and /files/{fileId} | The organisation’s files, or one file’s facts. Needs documents:read. | | DELETE /api/v1/compose/files/{fileId} | Delete the bytes and the id now. | - The bytes decide the type. PNG, JPEG, WebP and PDF are accepted; the name and the declared content type are never trusted. SVG is refused (it is a script container). - A PDF is refused (FILE_REJECTED, details.reason) if it is encrypted, damaged, has more than 500 pages or carries active content: JavaScript, launch actions, embedded files or forms that submit. - Files are encrypted at rest and belong to your organisation: another organisation’s id answers 404 STORED_FILE_NOT_FOUND, like one that does not exist. They expire after 30 days by default (we can set up to 365 days for your organisation on request); uploading the same bytes again returns the same id and moves the expiry forward. - One file at most asset_bytes (10 MB by default); a limited number of files per organisation (FILE_LIMIT_REACHED). Uploads accept an Idempotency-Key. ### Using a file An image in the payload. Where the payload would carry a data:image/... URI for :::img or an HTML img src, send the id instead: ``` { "receipt": { "$fileId": "7d3f6a52-..." } } ``` It is replaced by the image before the render, at most 20 per request. Your payload is stored with the reference, never with the pixels. PDFs after the document. settings.attachments takes up to 5 ids of stored PDFs, appended in order after the generated pages and before watermark, colour, compression, metadata and password, so those apply to everything. The annotations of the appended pages are dropped, so no link or form field from someone else’s PDF reaches the result. The pages count toward your page limit. ### Charts See the :::chart code: a bar, line or pie chart from numbers in the payload, in Word and HTML templates. ### Compression settings.compressionLevel (none, lossless, balanced, small) is described in the settings table. Measured on real templates with scripts/measure_compression.py: output from Word templates shrinks 27 to 60 % even with lossless (fonts, structure) and with no image loss; output from HTML templates is already tight and can grow, which is why the file is never returned larger than it was; a photo-heavy page shrinks by about 98 % with balanced, at 150 dpi. ### Signed download links | Call | What | | POST /api/v1/compose/documents/{id}/download-links | {"expiresInSeconds"?, "singleUse"?}. Mints a link. The url and token are in this answer only. | | GET /api/v1/compose/documents/{id}/download-links | The links of a document and their state: active, used, expired, revoked. Never the tokens. | | DELETE .../download-links/{linkId} and DELETE .../download-links | Revoke one link, or every live link of the document. | | GET /dl/{token} | Public: no key, no cookie. Returns the PDF. | These need documents:download. The console’s document page lists the links and revokes them too. - Short by default: 1 hour. You may ask for up to your organisation’s maximum (48 hours by default; we can set another for your organisation on request, never more than seven days); more is refused, never silently shortened. - Tamper-resistant: the token is signed over the link, your organisation, the document and the document’s SHA-256. A token for one document cannot fetch another, or another organisation’s. The answer of a mint carries the SHA-256 so you can check what you downloaded. - Revocable, at once. singleUse: true burns the link on its first request, even if the client then fails to read the body. - Answers: 404 DOWNLOAD_LINK_INVALID for an address that is unknown, altered, revoked or spent (one answer, so it cannot be probed); 410 DOWNLOAD_LINK_EXPIRED for a genuine one past its time. Rate limited per caller address; never cached (Cache-Control: no-store). - Every mint, use and revocation is an audit entry. A document composed dataless has nothing to link to. - The token is part of the URL path, so it can appear in access logs along the way. That is why the default is short and why you can revoke. ### PDF/A: converted, not verified settings.pdfA asks for a best-effort conversion. We do not validate the result against a PDF/A checker, so we never say the file conforms: every conversion returns the warning PDFA_NOT_VERIFIED and output.compose.pdfa.verified is false. Validate the file yourself if you need to rely on it. ## Bulk composition and signing Source: https://app.doconure.com/docs/bulk Compose many documents, or send many envelopes for signature, in a single request: submit inline or by an uploaded JSONL or CSV file, poll the job or take a completion webhook, download a ZIP of the results, retry what failed and see exactly what was charged. Only what was delivered costs credit. ## Bulk composition, signing and flow Compose many documents from one template, or send many envelopes for signature, with a single request. A batch is accepted whole or refused whole, runs in the background, and you read the result of every item back as it finishes. Only what was delivered is charged. Every document is rendered by exactly the code that renders a single POST /api/v1/compose/documents/generate, so a document in a batch is the same document you would have got one at a time, at the same price. A signing batch is the same: every envelope is made and sent by the code behind a single POST /api/v1/sign/envelopes?send=true, with the same validation, the same defaults and gates, the same refusals and the same error codes. A batch is never a way round a rule: whatever a single call would refuse, a batch refuses with the same code, and what a single call costs, an item costs. ### The calls | Call | Scope | What it does | | POST /api/v1/bulk/compose | bulk:submit and documents:compose | Submit a batch, inline or by fileId. Answers 202 with the job. | | POST /api/v1/bulk/sign | bulk:submit and signatures:ses | Submit a batch of envelopes to create and send (signing in bulk). Answers 202 with the job. | | POST /api/v1/bulk/flow | bulk:submit and flow:* | Submit a batch of flows: each item composes a document and puts it out for signature (Document Fly in bulk). Answers 202 with the job. | | POST /api/v1/bulk/files | bulk:submit | Upload a JSONL or CSV batch, get a fileId. Answers 201. | | GET /api/v1/bulk/jobs/{id} | bulk:read | The job: state, counts, credits and, once it is over, the reconciliation. Poll this. | | GET /api/v1/bulk/jobs/{id}/items | bulk:read | The result of each document, in order, in pages. | | GET /api/v1/bulk/jobs | bulk:read | Your organisation’s jobs, newest first (status, limit up to 100). | | GET /api/v1/bulk/jobs/{id}/results | bulk:read and documents:download | The result bundle: a ZIP of every produced PDF with a manifest and a results.csv. | | POST /api/v1/bulk/jobs/{id}/cancel | bulk:submit | Stop a job. | | POST /api/v1/bulk/jobs/{id}/retry | bulk:submit | Put the retryable failed items back, as new attempts in the same job. | The documents themselves are fetched with GET /api/v1/compose/documents/{id} (scope documents:download); each item carries its documentId and downloadUrl. Composition batches need the Composer Fly switch of your organisation to be on and signing batches need the Signer Fly switch, like the single calls; the job and the upload are shared, so a Signer Fly customer without Composer Fly can bulk-sign and read its own jobs. Each bulk operation is also a purchase of its own. On top of the product it belongs to (Composer Fly for /bulk/compose, Signer Fly for /bulk/sign, Document Fly for /bulk/flow), your organisation needs the matching bulk option, which we switch on for you. Without it the call is answered 403 FEATURE_NOT_ENABLED with details.feature set to bulkCompose, bulkSign or bulkFlow; nothing is reserved and nothing is charged. GET /api/v1/usage lists what your organisation holds under organisation.entitlements. ### Submitting a batch ``` POST /api/v1/bulk/compose Authorization: Bearer lpdc_... Idempotency-Key: month-end-2026-09 { "settings": { "templateId": "invoice-standard-8bc99b" }, "items": [ { "externalId": "inv-1001", "data": { "customer": { "name": "Acme Srl" }, "total": "1.240,00" } }, { "externalId": "inv-1002", "data": { "customer": { "name": "Beta SpA" }, "total": "380,00" } } ], "options": { "maxItems": 500 } } ``` - settings is the same object a single call takes and applies to every item. Each item’s data is what you would send as data to a single call. - externalId is your own reference for the item. It is optional, and unique within the batch. It comes back on the item so you can match results to your records. - Send either items or fileId, never both and never neither (BULK_INPUT_INVALID). - options.maxItems can lower the item limit for this request, never raise it. A batch over the limit is refused whole, never trimmed. - Unknown keys anywhere in the body outside an item’s data are refused, as everywhere in this API. The answer is 202 Accepted, with a Location header pointing at the job. The settings come back settled, and the job is in output.bulk: ``` 202 Accepted Location: /api/v1/bulk/jobs/6d1e... { "settings": { "templateId": "invoice-standard-8bc99b", "...": "as settled" }, "output": { "status": "pending", "bulk": { "jobId": "6d1e...", "operation": "compose", "status": "queued", "isTest": false, "templateId": "invoice-standard-8bc99b", "templateVersion": 3, "totalItems": 2, "counts": { "queued": 2, "processing": 0, "succeeded": 0, "failed": 0, "cancelled": 0 }, "creditsReserved": 10, "creditsConsumed": 0, "creditsHeld": 10, "creditsReleased": 0, "links": { "self": "/api/v1/bulk/jobs/6d1e...", "items": "/api/v1/bulk/jobs/6d1e.../items" }, "...": "createdAt, startedAt, completedAt, deadlineAt, resultsExpireAt, error" }, "credits": { "used": 0, "left": 1237 }, "warnings": [], "error": null } } ``` output.bulk is the fourth key of output and appears only on answers under /api/v1/bulk. The three product sections (compose, sign, flow) keep their place and are null there. #### What a batch cannot ask for A batch is refused whole with FEATURE_NOT_AVAILABLE_IN_BULK (1010), before anything is reserved, if its settings ask for a qualified seal (qeSeal), a qualified timestamp (qualifiedTimestamp), a document password, a responseFormat other than pdf, or a filename. details.fields names each. A seal or a timestamp is charged after the fact and could overdraw what was reserved; a password cannot be kept in a stored job; an item has no response of its own and one filename would name many files. Send those documents one at a time. ### Uploading a large batch Above the inline limit, upload the items as a JSONL file (one JSON object per line, {"externalId": "...", "data": {...}}) — or as a CSV — and submit with fileId: ``` POST /api/v1/bulk/files Authorization: Bearer lpdc_... Idempotency-Key: month-end-2026-09-file Content-Type: application/x-ndjson {"externalId":"inv-1001","data":{"customer":{"name":"Acme Srl"}}} {"externalId":"inv-1002","data":{"customer":{"name":"Beta SpA"}}} 201 Created { "output": { "bulk": { "file": { "fileId": "f41a...", "items": 2, "bytes": 131, "sha256": "...", "expiresAt": "2026-10-03T09:14:02Z" } }, "...": "..." } } POST /api/v1/bulk/compose { "settings": { "templateId": "invoice-standard-8bc99b" }, "fileId": "f41a..." } ``` - Send the lines as the raw request body. Nothing is decompressed: a Content-Encoding is refused. - The whole file is validated before anything is stored, and a JSONL is refused whole if any line is wrong (BULK_INPUT_INVALID, with up to twenty details.problems, each naming its line). A file over its byte limit is JOB_TOO_LARGE (1812). - A file is used by one job. An upload that is not used before expiresAt is deleted; using it afterwards, or twice, is BULK_FILE_NOT_FOUND (1503). ### CSV input A spreadsheet export is a batch too. Upload it to the same POST /api/v1/bulk/files with Content-Type: text/csv (or ?format=csv) and submit the fileId as for a JSONL. It is read into exactly the same items, so everything after the reader — the limits, the reservation, the rendering, the charging — is the same. | Header | Becomes | | externalId | The item’s own reference (not a payload field). | | customer | data.customer, text. | | customer.name | data.customer.name: a dot nests. | | lines.0.sku, lines.1.sku | A list: data.lines[0].sku, data.lines[1].sku. Indexes run 0, 1, 2… with no gap. | | total:number, paid:bool, lines:json | A typed cell. Without a suffix a cell is text. :number is digits with an optional minus and a dot for decimals; :bool is true/false, yes/no, 1/0; :json is a JSON value. | - The first row is the header. An empty cell is absent (the field is not sent), never an empty string, and a list element whose cells are all empty is dropped. - Delimiter: comma, semicolon, tab or pipe, read from the header line, or given with ?delimiter=comma|semicolon|tab|pipe. It is read once, at upload, and stored with the file. - Encoding: UTF-8 (a byte-order mark is fine), or ?encoding=iso-8859-1 / windows-1252 — what an Excel in Italy still writes. It is never guessed: a file that is not valid in its encoding is refused with the byte it failed on. - A bad file is refused whole with BULK_INPUT_INVALID and nothing is stored: empty, NUL bytes, a header that does not parse (a duplicate or unnamed column, an unknown type suffix, a list with a gap), a malformed quote, more rows than the file limit, or no usable row at all. - A bad row does not refuse the others. It becomes a failed item with error.code BULK_INPUT_INVALID (1107) and its row and line: a cell that is not the number its header says, the wrong number of cells, a duplicate or invalid externalId, a row over the per-item size. It is never reserved and never charged, and it is not retryable (fix the row and send it in a new batch). - Formulas: what you send is never evaluated or altered; a cell that starts with =, +, - or @ stays text, because it goes into a PDF. Every CSV we write (results.csv) prefixes such text with an apostrophe so a spreadsheet cannot run it. - The columns are bounded (200, nested at most 6 deep, list index at most 99) and nothing is decompressed. ### Signing in bulk POST /api/v1/bulk/sign sends one envelope per item. It is POST /bulk/compose with a different item, so everything on this page — the 202 and the job, admission whole or not at all, one reservation per item, the worker, the states, Idempotency-Key, the limits, CSV, the bundle, the retry and the webhook — applies as it is. An item is made and sent by the same service a single POST /sign/envelopes?send=true uses. ``` POST /api/v1/bulk/sign Authorization: Bearer lpdc_... Idempotency-Key: nda-2026-q4 { "settings": { "documents": [ { "fileId": "9c2e..." } ], "subject": "NDA 2026" }, "items": [ { "externalId": "C-001", "signers": { "A": { "email": "anna@example.com", "fullName": "Anna Rossi" } } }, { "externalId": "C-002", "subject": "NDA 2026 - special terms", "documents": [ { "fileId": "b17a..." } ], "signers": { "A": { "email": "bruno@example.com", "fullName": "Bruno Neri" } } } ], "callbackUrl": { "onSuccess": "https://example.com/hooks/bulk" } } ``` - settings is the shared part of a single envelope’s settings (isTest, level, language, subject, documents, …). An item’s own documents / documentId / subject / message are laid over it, and the result is validated as the very request a single call takes. The same contract for every signer is therefore settings.documents once and signers per item. - Sources are a composed documentId or a PDF you uploaded once (POST /api/v1/sign/files, then fileId). An inline fileBase64 is refused in a batch: upload it once. - A CSV works too: externalId,signers.A.email,signers.A.fullName with the shared settings in the request. A row with an invalid address is a failed, uncharged item. - One signature is reserved per item at admission and charged when that envelope is sent; an item that fails is refunded. An isTest batch spends one test item per envelope and never credit. - settings.callbackUrl is the callback of each envelope; the top-level callbackUrl is the completion webhook of the batch. - The token needs bulk:submit and signatures:ses: a batch never gets round a scope. Refused whole, with the codes a single call gives: FEATURE_NOT_AVAILABLE_IN_TEST_MODE (1009) for a paid feature with isTest; SIGNATURE_LEVEL_UNAVAILABLE for AES or QES; INVALID_REQUEST_BODY for a missing subject or a malformed signer; QUOTA_EXCEEDED. A seal or timestamp — asked for, or required by your organisation — is refused with FEATURE_NOT_AVAILABLE_IN_BULK (1010): it is charged at completion and cannot be reserved up front. A source that cannot be used (STORED_FILE_NOT_FOUND) fails its item, uncharged, with the single call’s code. Each item says which envelope it made (envelopeId); follow it with GET /api/v1/sign/envelopes/{id}. ### Document Fly in bulk POST /api/v1/bulk/flow runs one flow per item: the document is composed from the template and put out for signature in the same step, exactly as a single POST /api/v1/flow does it. Everything on this page applies: the 202 and the job, admission whole or not at all, the worker, the states, Idempotency-Key, the limits, CSV, the bundle, the retry and the webhook. ``` POST /api/v1/bulk/flow Authorization: Bearer lpdc_... Idempotency-Key: contracts-2026-q4 { "settings": { "templateId": "tpl_..." }, "signature": { "subject": "Service agreement" }, "items": [ { "externalId": "C-001", "data": { "customer": "Acme S.r.l." }, "signers": { "A": { "email": "anna@example.com", "fullName": "Anna Rossi", "fields": [ { "page": 1, "x": 0.1, "y": 0.8, "width": 0.3, "height": 0.08 } ] } } } ] } ``` - settings is the composition half of a single flow’s settings and applies to every item; signature is the shared part of its signature block (everything but signers). An item brings its data and its signers, and may lay its own subject, message and documents (annexes) over the shared block. - Each item reserves two things at admission, a document and a signature, as a single flow does, and both are charged when the item is delivered. An item that fails is refunded; a flow whose document was composed before its envelope was refused has charged that document once (the job’s creditsConsumed counts it, through any number of retries), and a retry does not charge it again. An isTest batch spends one test item per flow and never credit. - The token needs bulk:submit and flow:*. Refused whole, with the codes a single flow gives: FEATURE_NOT_AVAILABLE_IN_TEST_MODE for a paid feature with isTest; SIGNATURE_LEVEL_UNAVAILABLE for AES or QES; DATALESS_NOT_AVAILABLE; QUOTA_EXCEEDED; FEATURE_NOT_ENABLED for an SMS code or a signature level your organisation has not bought. A seal or timestamp is refused with FEATURE_NOT_AVAILABLE_IN_BULK. Each item says which document and which envelope it made. ### The job A job moves queued → running → one of succeeded, partially_succeeded, failed or cancelled. Poll on output.status: it is pending while the job can still change, then succeeded only when every item delivered, and failed for anything else (a partial result, a cancelled job, a failed one), so read output.bulk.status and the items to tell those apart. Do not infer the state from the counts. | Final status | When | | succeeded | Every item delivered a document. | | partially_succeeded | Some delivered, some failed. The job itself is not an error: read the items. | | failed | No item delivered a document. | | cancelled | You asked for it to stop; this wins over the other three. | Each item is queued, processing, succeeded, failed or cancelled. counts has one number for each, totalItems is what you submitted, and pending is queued plus processing. Results are kept for the retention period shown in resultsExpireAt (set once the job is over); afterwards resultsExpired is true. Fetch what you need before then. ### Reading the results ``` GET /api/v1/bulk/jobs/6d1e.../items?status=failed&limit=100&after=41 { "output": { "bulk": { "jobId": "6d1e...", "...": "the job, as above", "items": [ { "index": 57, "externalId": "inv-1058", "status": "failed", "documentId": null, "downloadUrl": null, "creditsUsed": 0, "error": { "code": "MISSING_TEMPLATE_FIELD", "number": 1401, "message": "...", "retryable": false }, "warnings": [], "...": "attempts, pageCount, sizeBytes, sha256, durationMs, startedAt, finishedAt" } ], "page": { "limit": 100, "after": 41, "status": "failed", "nextAfter": 98 } } } } ``` - Items come in index order (the position in your batch, from 0). status filters by item status; limit is up to 500. - To read the next page, send after = the nextAfter you were given. When nextAfter is null, that was the last page. - A delivered item carries its documentId, downloadUrl, page count, size and SHA-256. A failed one carries the stable error.code and number (see Outcomes and error codes), and retryable says whether the same item is worth sending again. - The platform never retries an item for you. You ask for it, with the retry call; an item that is not retryable, or whose payload is not kept, you resubmit in a new batch (reuse its externalId). ### The result bundle GET /api/v1/bulk/jobs/{id}/results returns one ZIP once the job is over: documents/.pdf for every produced file, manifest.json and results.csv (per item: status, attempts, documentId / envelopeId, the file’s SHA-256, page count, credits used, the error code and number, whether it is retryable) and the job’s credits. A signing job’s bundle holds the manifest and the CSV only. - It needs bulk:read and documents:download — it hands out the bytes the per-document download does. Another organisation’s job is a 404. - It exists only once the job is over and while its results are kept; otherwise BULK_RESULTS_UNAVAILABLE (1504) with details.reason job_not_finished or results_expired. A document your own retention has deleted is listed with file: null. - It is built one document at a time into a temporary file and capped. A job whose documents are over the cap is JOB_TOO_LARGE; fetch it in ranges with ?after=&limit= (the manifest’s range.nextAfter is what to send next). - Entry names are the item’s index, never what you wrote; results.csv prefixes an apostrophe to any text that a spreadsheet could read as a formula. - Downloading it costs nothing and is audited. For one document for somebody who has no key, mint a signed download link for its documentId. ### Retrying failed items POST /api/v1/bulk/jobs/{id}/retry puts the retryable failed items of a finished job back to work, as new attempts in the same job (attempts counts them, retryCount counts the calls). Each item is admitted like a new one: its own reservation, the same limits and the same worker; a retry that cannot be paid for is refused whole and changes nothing. The answer is 202 with the job and retry: {requeued, skippedCount, skipped[]}. | An item is put back when… | otherwise it is skipped as | | its failure says retryable: true | not_retryable | | its payload is still held (an organisation that composes without keeping data holds none) | payload_not_kept | | it has run fewer than 3 times | attempts_exhausted | | no paid provider request of it has an unknown outcome | outcome_unknown: never repeated, it waits for its reconciliation | Nothing retryable is BULK_RETRY_NOT_POSSIBLE (1505) with details.reason (nothing_retryable, job_not_finished, job_cancelled, results_expired, payload_not_kept) and the skipped items. It is idempotent: a second call while the first one’s items run finds the job unfinished and changes nothing, and with an Idempotency-Key the first answer is replayed. It needs bulk:submit and the scope of the job’s operation (documents:compose, or signatures:ses for a signing job; isTest for a test job). The job ends again, and tells you again. ### Completion webhook Add "callbackUrl": {"onSuccess": "https://...", "onError": "https://..."} to a compose, sign or flow batch and you are told when the job ends, instead of polling. onSuccess is called when every item delivered, onError for every other end (partially_succeeded, failed, cancelled). It is the same delivery as a signature callback: https only, signed (X-Signer-Signature: HMAC-SHA256 of the raw body under your organisation’s callback secret), never to a private or non-public address, no redirects followed, retried on the same backoff (up to about two days), listed in the webhook log and replayable there. Headers add X-Signer-Bulk-Job. To authenticate at your receiver, name a callback environment and send paths: "callbackUrl": {"env": "prod", "onSuccess": "/hooks/bulk"}; an unknown env is refused at submission, before anything is reserved. ``` { "bulkJobId": "6d1e...", "operation": "compose", "status": "partially_succeeded", "isTest": false, "totalItems": 100, "counts": { "succeeded": 97, "failed": 3, "...": "..." }, "creditsConsumed": 485, "creditsReleased": 15, "retryCount": 0, "reconciliation": { "...": "as in the job" }, "links": { "self": "...", "items": "...", "results": "..." }, "event": { "id": "evt_...", "type": "bulk.partially_succeeded", "occurredAt": "...", "schema": 1 } } ``` event.id is stable across retries: deduplicate on it. The body names the job and the numbers and carries no item data; fetch the items, paginated. A job that is retried and ends again sends a new event. The URL is never echoed back by the API. ### Credits: credits.total and credits.block Like every answer, a bulk answer carries output.credits as {used, left}. For a job, used is the total of what its items settled so far (the sum of the per-item creditsUsed; 0 for isTest), and left is your balance read from the ledger now — never your previous balance minus used, which is wrong while the job holds a reservation. Inside output.bulk the same money is broken down for the job: | Field | Meaning | | creditsReserved | What was set aside for the batch at admission: one document’s price per item. | | creditsConsumed | What the delivered documents actually cost. | | creditsHeld | Still reserved for items not yet finished. 0 once the job is over. | | creditsReleased | Given back: failed or cancelled items. | Once the job is over it also carries a reconciliation: ``` "reconciliation": { "requested": 100, "succeeded": 97, "failed": { "platform": 1, "customer": 1 }, "cancelled": 1, "creditsReserved": 500, "creditsConsumed": 485, "creditsReleased": 15, "chargedFailureClasses": [] } ``` creditsConsumed + creditsReleased = creditsReserved. The failures are split into platform (ours: an internal error, a rendering fault, a job deadline) and customer (the input) so you can see which is which; neither is charged. ### What is charged A document is charged if, and only if, it was delivered. A bulk job costs exactly what the same documents sent one at a time would. | Outcome | Charge | | The batch is refused (malformed, over a limit, not enough credit, no capacity) | Nothing is reserved, nothing is charged. | | An item fails on its data or its template | Not charged; its credit goes back. | | An item fails for a platform reason | Not charged; its credit goes back. | | An item is cancelled before it started, or is still waiting at the job deadline | Not charged; its credit goes back. | | An item is delivered | Charged one document. | | An isTest item | Uses the test allowance, never credit. See below. | The credit for the whole batch is reserved in one step before any document is rendered, so two batches that together are more than your balance cannot both be admitted: the second is refused with QUOTA_EXCEEDED (see Outcomes and error codes) and nothing is left behind. It is reserved per item, and each item settles on its own as it finishes. ### Cancelling POST /api/v1/bulk/jobs/{id}/cancel stops the job. Items that have not started are cancelled and their credit is released at once; an item being rendered right now finishes and is charged if it delivers. The answer is the job (cancelRequested is true). Cancelling a job that is already over changes nothing and answers with it. The call takes an Idempotency-Key like the others. ### Idempotency Submitting, uploading a file, cancelling and retrying all take an Idempotency-Key, and for a submit you should always send one. With it, a retry of a request that timed out returns the same job (the answer carries Idempotent-Replay: true) instead of admitting and charging a second batch. The same key with a different body is IDEMPOTENCY_KEY_REUSED. The rules are those of every other call; use a key from your own domain, such as a batch or period reference. ### Testing: isTest With "isTest": true in settings the whole batch runs in the sandbox: no credit is spent and the documents are marked as tests. The batch draws one test-allowance item per document from the same pool as single test calls, all or nothing, and is capped at a small number of items per batch (the current default is 5). A test batch over the cap is BULK_ITEM_LIMIT_EXCEEDED. Test items are scheduled behind production work. ### Dataless organisations If your organisation composes dataless, a bulk batch still keeps its documents for your normal retention — a batch answers with no bytes, so you fetch them afterwards — and the answer carries the warning DOCUMENT_STORED_FOR_BULK (2203). The data you sent is not recorded; each item’s payload is wiped when the item finishes. ### Limits These are the current defaults. They are working values and may change; the ones that apply to your organisation can be lower, never higher. A request is refused whole, with nothing reserved, when it is over any of them. | Limit | Default | Refused with | | Items in an inline batch | 500 | BULK_ITEM_LIMIT_EXCEEDED | | Items in an uploaded file | 2,000 | BULK_ITEM_LIMIT_EXCEEDED | | Size of an inline body / of an uploaded file | 10 MiB / 25 MB (no request may be larger) | JOB_TOO_LARGE / PAYLOAD_TOO_LARGE | | Items in an isTest batch | 5 | BULK_ITEM_LIMIT_EXCEEDED | | Jobs running at once | 2 | BULK_CONCURRENCY_LIMIT_REACHED | | Items waiting across your jobs | 20,000 | BULK_CONCURRENCY_LIMIT_REACHED | | Submissions per minute | 6 | BULK_CONCURRENCY_LIMIT_REACHED | | Uploaded files not yet used | 5 | BULK_CONCURRENCY_LIMIT_REACHED | The effective item limit is the lowest of what you ask in options.maxItems, your organisation’s own limit and the platform’s; details.requestedItems and details.allowedItems say by how much you were over. BULK_CONCURRENCY_LIMIT_REACHED is retryable and carries Retry-After: details.what says which limit it was. A job still running six hours after it was admitted times out its remaining items (BULK_JOB_TIMED_OUT, not charged). Items are rendered one after the other and share capacity with everybody else’s requests, so a large batch is a matter of minutes, not seconds; the platform pauses a job rather than failing it when it is busy. If bulk is switched off for the whole service, a new batch is refused with SERVICE_PAUSED. ### Scopes A compose batch needs bulk:submit and documents:compose; a signing batch needs bulk:submit and signatures:ses; a flow batch needs bulk:submit and flow:*. The first authorises running a batch, the second authorises the operation under it, and a refusal (TOKEN_SCOPE_NOT_ALLOWED, 403) names the first scope missing in details.missingScopes. The bulk scope group is: bulk:submit to submit, upload, cancel and retry, bulk:read to read jobs, items and the result bundle (which also needs documents:download), or bulk:* for both. Ticking bulk:submit does not tick the operation’s scope for you: tick both on the API keys page. Tokens issued before bulk existed do not get these scopes; issue a new token with them ticked. Another organisation’s job or file answers 404, exactly like one that does not exist. ### In the console A batch is a list of single calls, and the console shows it that way. Every document a compose batch made is a row of Documents and every envelope a signing batch sent is a row of Signatures, marked Batch with a link to its batch; the filter above each list shows all rows, only single calls or only batches. A batch started with an API key is there without anything to set up (Documents, then Batches, at /tenant/batches). The results of a batch are kept for the retention period; after that its documents are still in Documents, but no longer marked. The batch page shows its status, its type, how many items are done, failed, cancelled and pending, a progress bar, and the credits reserved, used and returned (the same figures as output.credits; a test batch says so and shows no credit). Every item links to its own document or signature request, and a failed one shows its stable error code, its number and its message. Anyone who can see Documents can read the page and download the results ZIP. A coordinator can also retry the failed items (only the retryable ones, as the API does), cancel a running batch (what had not started stops and its credit returns) and send the completion webhook again; each of the three is recorded in the audit trail under the person’s name. ### Not built yet - No seal, timestamp or password on a bulk document or a bulk envelope: they are charged or kept after the fact and cannot be reserved up front (send those one at a time). - No AES or QES, in a batch or singly (SIGNATURE_LEVEL_UNAVAILABLE). - A signed download link for the whole bundle: the ZIP is fetched with your key; a link is minted per document. ## Versions Source: https://app.doconure.com/docs/versions Upload a new version without breaking the integration that pinned the old one. Uploading is not publishing. ## Versions Every upload of a template creates a new version, numbered from 1. The rules: - A new version arrives inactive. Uploading is not publishing: it goes live when someone presses Activate. - Several versions can be active at the same time. - A request that does not pin a version gets the highest active version number. Activating a newer version therefore rolls traffic forward with no change on the caller's side. - A request pinning a version that is not active is refused, never silently served a different one. The error names the versions that are active, so a caller can recover. - Deactivate template switches every version off at once and stops all generation for it. It asks for confirmation, and it is reversible: activate a version again afterwards. This is what versions are for: if a change to a template breaks something, activate the previous version and you are back in service in one click, without re-uploading anything. ## Compose and sign, in one call Source: https://app.doconure.com/docs/flow The full reference for POST /api/v1/flow: the request, the three output sections, idempotency, and what it refuses before it charges you. ## Compose and sign, in one call POST /api/v1/flow This is Document Fly — the suite. It generates the document with Composer Fly and immediately puts it out for signature with Signer Fly, so you do not have to hold state between two calls or handle the case where the first worked and the second did not. The body is the generate body plus one key. data and settings are exactly what Generating a document takes; everything about the signature is under signature, in the same shape as POST /api/v1/sign/envelopes minus documentId — the document does not exist yet when this is written: ``` POST /api/v1/flow Authorization: Bearer lpdc_... Idempotency-Key: order-4471 { "data": { "customer": { "name": "Acme Srl" } }, "settings": { "templateId": "contract-standard-8bc99b" }, "signature": { "subject": "Service agreement 2026", "message": "Please read clause 4 before signing.", "level": "SES", "hasSigningOrder": false, "signers": { "A": { "email": "anna@acme.example", "fullName": "Anna Rossi", "validationMode": "email", "fields": [ { "kind": "signature", "page": 1, "x": 0.1, "y": 0.78, "width": 0.32, "height": 0.09 } ] } } } } ``` Where the signature goes. Put {{ signature.A }} in the template and leave out fields: signer A signs where the tag is, and the tag is removed from the document before it is sealed or stored. A tag in the template for a signer you did not send, or (when the template uses tags) a signer with neither a tag nor fields, is refused before anything is composed or charged. Two cases are only known once the document exists, and are then refused with the document charged and named in details.documentId: a tag inside a condition that turned out false, and a tag typed in an uploaded annex (signatureTags). fields (fractions of the page from the top-left) remain the alternative and win when given: Where the signature goes on the page. Send an Idempotency-Key. It is optional and you should not treat it as such. This call charges twice and emails real people; a client that retries a request that timed out — and clients do — will produce a second document and a second set of emails without one. With one, the retry answers 200 and hands back the flow you already made instead of 201 and a new one. The key is scoped to your organisation, so it only ever has to be unique to you. ### What comes back The document already exists when this returns — generation is synchronous and takes well under a second, so the link is a file and not a promise. What takes time is the signature, and nothing waits for it. ``` 201 Created { "settings": { "templateId": "contract-standard-8bc99b", "...": "as settled" }, "output": { "status": "pending", "flow": { "flowId": "3f9c...", "status": "awaiting_signature", "closed": false, "terminal": false, "statusUrl": "/api/v1/flow/3f9c...", "eventsUrl": "/api/v1/flow/3f9c.../events", "createdAt": "2026-09-20T09:14:02Z", "updatedAt": "2026-09-20T09:14:02Z", "closedAt": null, "closedReason": null }, "compose": { "documentId": "9b21...", "downloadUrl": "/api/v1/compose/documents/9b21...", "templateId": "contract-standard-8bc99b", "templateVersion": 3, "sha256": "cdc82a...", "pageCount": 4, "...": "the same section /api/v1/compose answers with" }, "sign": { "envelopeId": "7a04...", "status": "sent", "level": "SES", "expiresAt": "2026-09-29T20:32:43Z", "seal": false, "timestamp": false, "signedDocumentUrl": null, "recipients": [ { "email": "anna@acme.example", "status": "notified" } ], "...": "the same section /api/v1/sign answers with" }, "credits": { "used": 15, "left": 1232 }, "warnings": [], "error": null } } ``` One section per URL. A flow drives the other two products, so it fills all three: output.flow is the flow's own state, output.compose is byte for byte the section /api/v1/compose answers with, and output.sign is the one /api/v1/sign answers with. Code you wrote against either of those endpoints reads a flow without changing. Two names that catch people out, both inherited rather than invented here: you send signature.signers — in a flow everybody signs — and the envelope answers recipients, because Signer Fly will grow approvers and viewers that do not sign. And output.status is about the call (pending until the flow closes); output.flow.status is the flow's own five-word vocabulary. Credits, for the whole flow. output.credits.used is the document and the signature (and the seal and timestamp, paid when the envelope is sent), plus each SMS code as it leaves, so a poll that finds the flow closed carries the definitive cost, and output.credits.items lists it line by line; an isTest flow is used: 0 and also carries testRemaining, the test items left today. left is your balance from the ledger at the moment of the answer. If the envelope fails after the document was composed, the error answer's output.credits.used is the document's cost — the one failure that is not zero. Details: Credits in every answer. Breaking change, 2026-09-20. This response used to be flat: flowId, document, signature and signedDocument at the root. There is no fallback — the keys moved, they were not duplicated. document.id is output.compose.documentId, document.url is output.compose.downloadUrl, and signedDocument.url is output.sign.signedDocumentUrl. The same release moved /api/v1/sign's answers under output.sign and renamed the envelope's id to envelopeId, so that the three sections name their ids the same way: documentId, envelopeId, flowId. seal and timestamp are echoed, not repeated. Both are gated by the organisation's own settings, which are a ceiling rather than a default — so what you sent and what will happen are not the same question. Read them from the response. When the organisation overrules you the call still succeeds and output.warnings says so, with a stable code: Warnings. Once the flow is signed, output.sign.padesProfile reports what the file actually carries. A flow cannot compose dataless. settings.dataless: true here is refused with DATALESS_NOT_AVAILABLE, before anything is composed or charged: the document has to survive in order to be shown to a signer, and its evidence is kept for ten years. Compose it at /api/v1/compose/documents/generate if nothing may be stored. If your organisation has Never store the data you send us switched on, a flow still runs. What you sent is still not recorded; the document itself is kept, and warnings carries DOCUMENT_STORED_FOR_SIGNATURE so that nobody has to infer it. ### Following it GET /api/v1/flow/{flowId} Poll this until output.status stops being "pending". Branch on that, not on a list of statuses you hardcode — that is what it is for, and a status added later will not turn your integration into a loop that never ends. When the flow reaches signed, output.sign.signedDocumentUrl is the finished article: the document, the marks, and the certificate of completion appended. | output.status | Means | | pending | Links are out, somebody has still to sign. Keep polling. | | succeeded | Everyone signed. output.sign.signedDocumentUrl is populated. | | failed | It closed without a signature: declined, expired or cancelled. output.flow.status says which. | And the flow's own five words, under output.flow.status, for when you need to tell the three ways of failing apart: | output.flow.status | closed | Means | | awaiting_signature | false | Links are out. Keep polling. | | signed | true | Everyone signed. output.sign.signedDocumentUrl is populated. | | declined | true | Somebody refused. Nothing is signed — see output.sign.recipients[].declineReason. | | expired | true | The deadline passed with signatures outstanding. | | cancelled | true | You withdrew it. | You can be called back instead of polling. Set signature.callbackUrl.onSuccess and onError and we POST to them when the flow reaches a terminal state — completed to the first, declined, expired and cancelled to the second. The body is the same shape the poll returns, so one handler serves both. Told when a signer is replaced. A third, optional address, signature.callbackUrl.onEvent, receives the event recipient.replaced, signed and retried like the other two, which keep saying only how the flow ended: Being told when a signer is replaced. A receiver that wants a token (Salesforce Apex REST, an API gateway): name a callback environment and send only paths, "callbackUrl": {"env": "prod", "onSuccess": "/doconure/ok"}. We add the environment's Bearer token, API key or OAuth2 token to every callback: How do I make our callbacks authenticate at my system? Three things worth knowing before you build on it: - The URL must be https, and must resolve to a public address. A private or loopback address is refused and not retried — that refusal is a guard against this endpoint being used to reach inside our network, not a configuration nicety. - Verify the signature. Every request carries X-Signer-Signature: sha256=, computed over the exact raw body with your tenant's own key. Compare it before trusting the payload; anyone can POST to your URL. - It arrives within about two minutes, not instantly. Delivery deliberately never happens inside the request that closed the envelope, because that request is a signer waiting on a page. A receiver that is down is retried on a widening backoff — eight attempts over roughly 44 hours, so an endpoint that is down overnight still hears about it — and then given up on. Whatever happens to the callback, GET /api/v1/flow/{flowId} still answers the truth. - Deduplicate on event.id. The body carries an event block — id, type, occurredAt and a schema number — and the id is the same on every retry of the same event. A receiver that processed us and then timed out will be called again; without this it cannot tell that from a second signature. occurredAt is inside the signed body so a captured request replayed later can be rejected on age. Polling still works and is still supported. If you cannot accept an inbound request, nothing changes for you. GET /api/v1/flow lists your flows, newest first; add ?kind=production or ?kind=test so test calls and real ones do not end up in one list. POST /api/v1/flow/{flowId}/cancel withdraws the request (the console calls it Revoke). Every outstanding link stops working immediately, and each signer who had been invited and had not finished is told it was revoked. A signer of a flow's envelope is replaced like any other: Replacing a signer. The document is untouched and stays fetchable — it exists and was paid for — and neither the document nor the signature is refunded, because the work was done and the mail went out. The envelope's seal and timestamp, if it paid them at the send, are returned to the credit they came from, and the same happens when the envelope expires or is declined, or when they cannot be applied (what comes back). ### What it costs, and what it refuses A flow costs a document plus a signature. Both are reserved together, in one atomic step before anything is generated (two calls racing for the last credits cannot both start): if your balance covers one but not both, the call is refused with QUOTA_EXCEEDED and a details.breakdown of the two rates, and nothing is made or charged. The same is true of an unavailable signature level — see Signature levels: SES, AES, QES. A flow never leaves you holding a document you cannot send. ## Signing on its own Source: https://app.doconure.com/docs/signing-envelopes The signature API when the document already exists: signers, settings, the response envelope, and delegation. ## Signing on its own If the document already exists — you generated it earlier, or you want to review it before anyone is asked to sign — the signature API is separate: | Call | Does | | POST /api/v1/sign/files | Upload a PDF you already have and get a fileId. Charges nothing; see below. | | GET /api/v1/sign/files, GET /api/v1/sign/files/{fileId}, DELETE /api/v1/sign/files/{fileId} | List your PDFs, read one's facts (never its bytes), delete one nobody needs. | | POST /api/v1/sign/envelopes | Draft an envelope over a document you produced or a PDF you uploaded. Nothing sent, nothing charged — unless you add ?send=true, which drafts and sends in one call (and is then the billable moment). | | POST /api/v1/sign/envelopes/{id}/send | Issue the links and email them. This is the billable moment. | | GET /api/v1/sign/envelopes/{id} | Where it has got to, and what each signer has done. | | GET /api/v1/sign/envelopes/{id}/document | The signed PDF, once complete. | | GET /api/v1/sign/envelopes/{id}/evidence | A ZIP with the signed PDF, the source document and the full event trail as JSON — everything needed to defend the signature outside this system. Any closed envelope, not only a completed one. | | POST /api/v1/sign/envelopes/{id}/cancel | Revoke it (the console button says Revoke; the path stays /cancel). Every link dies, and each signer who had been asked is told it was revoked. | | POST /api/v1/sign/envelopes/{id}/recipients/{recipientId}/replace | Put somebody else in a signer's place (email, fullName, optional reason; phone when the signer confirms with a code by SMS): the fields and the place in the signing order go to them, the old link dies and its holder is told. On a draft it simply corrects the signer, telling nobody. A signature already given is never undone. Not a charge. | | POST /api/v1/sign/envelopes/{id}/recipients/{recipientId}/resend | Reissue one signer's link — the old one dies immediately. Not a second charge. | #### Replacing a signer When the wrong person was named, or somebody is no longer available, replace the signer instead of leaving the request open. Send {"email": "...", "fullName": "...", "reason": "..."} to .../recipients/{recipientId}/replace while the envelope is a draft or open (sent or in_progress). On a draft nothing has been sent and nobody holds a link, so the call simply corrects the signer's name, address (and phone): no message goes out, no callback is sent, and the change is kept as one event on the envelope's trail and one line in the audit log. A signer who confirms with a code by SMS (validationMode: "sms") needs the new person's "phone" as well: the old number is never kept, and without it the request is refused (INVALID_REQUEST_BODY, details.field: "phone"; a number the code cannot reach is SMS_VALIDATION_UNAVAILABLE). A signer who has signed or declined, or whose group was completed by somebody else (skipped), cannot be replaced. The slot is handed over in place, so the signature fields and the signing order stay exactly where they were; the old link stops working at once and its holder receives a message that the request was withdrawn; the new person is invited when it is their turn. The answer's delegatedFrom names who held the slot, with replacedBy set (it is empty when a signer passed their own turn), and the certificate of completion says in words that the sender replaced them; its event trail lists each event under the person who held the slot at the time. Somebody who opens a link that was replaced, withdrawn or that expired before it was used is told which of the three it was, in the envelope's language; the page shows nothing else and the link can do nothing. A signer who passes their own turn on (when you allow it) is held to the same rules: on a signer who confirms with a code by SMS the signing page asks for the new person's mobile number, and the old number is never kept. Use cancel to end the whole request. When a request is cancelled or expires, every signer who had been asked and had not finished is told; somebody who was never invited (a later group of an ordered request) is not. Organisations can also have the mails their signers receive carry their own logo, colours, footer and wording, and have each signer sent the signed document: the first is under Branding (how), the second in Settings, under Notifications. #### The subject of the mails Every mail we send that does not carry your branding has a subject that starts with “DoConure - ”, so a signer recognises it in the inbox: the invitation reads DoConure - followed by your subject. A branded mail keeps your organisation's subject as you wrote it, without our name. In the text of every mail a time is written in your organisation's locale, with its zone. The button sits in the middle of the mail with its address written out underneath, for a mail program that does not show buttons. #### The mails your signers receive, in your look Off until you switch it on (Branding → Brand). You set a header colour, a button colour, a footer colour and a footer text (a contact line, a notice) in Italian and in English, and the logo you already uploaded is shown at the top. Each mail carries the footer text of its envelope's language; when that one is empty, the other. It is never HTML you write: our one template applies those values, and the plain-text part of every mail is the same message with your footer text in it. The logo travels inside the mail, so nothing is fetched when it is opened. The greeting uses the signer's name as you sent it, whole. It covers the invitation, the reminder, the revoked and expired notices and the signed copy. Two things never change. The verification code mail is always ours: a code is not a place for branding. And a branded mail loses our own “DoConure by Limepeak” line, since the footer is yours, but keeps the automatic-message note and the link for reporting a document the person was not expecting. A test envelope's mail still opens with the red TEST warning. The evidence bundle is meant to leave this system: hand it to a lawyer, an auditor, or a customer's own archive without giving them access here. A declined or cancelled envelope can produce one too — proving someone refused to sign is still evidence. Signature evidence is kept for 10 years after an envelope closes and the source document for 365 days after it is generated; past either window the corresponding file is silently omitted rather than failing the whole export, and evidence.json's own fields (hashes, timestamps, closedReason) still answer what happened. ### Sign a PDF you already have Signer Fly works without Composer Fly. Upload the PDF once, then name its fileId in any number of envelopes: ``` POST /api/v1/sign/files Idempotency-Key: nda-2026-contract-v3 { "fileName": "nda.pdf", "fileBase64": "JVBERi0xLjQK..." } 201 Created { "output": { "sign": { "file": { "fileId": "6d1c...", "sha256": "ab12...", "pageCount": 3, "sizeBytes": 48211, "created": true } } } } POST /api/v1/sign/envelopes?send=true { "signers": { "A": { "email": "anna@acme.example", "fullName": "Anna Rossi", "fields": [ { "kind": "signature", "page": 3, "x": 0.1, "y": 0.78 } ] } }, "settings": { "documents": [ { "fileId": "6d1c..." } ], "subject": "NDA" } } ``` - One file, many envelopes. The file is referenced, never copied, and it never changes: a new version is a new upload and a new fileId. Each envelope records the file's sha256; add "sha256": "..." beside a fileId to be refused (SOURCE_FILE_CHANGED) if it is not the file you mean. - Identical bytes are one file. Uploading a PDF your organisation already holds answers 200 with the existing fileId and created: false. Another organisation's file is never revealed: its id is a 404. - One-shot. { "fileBase64": "...", "fileName": "nda.pdf" } in settings.documents is the same upload inline: same validation, same storage, and the file is then on your account like any other. A flow's extra documents take the same three forms; the document a flow composes is still always first. - What is refused (FILE_REJECTED, with details.reason): not a PDF, malformed, encrypted, more than 200 pages, over the size limit, or carrying JavaScript, a launch action, an attached file, XFA or rich media — the signer opens the file in their own browser, so nothing that runs is accepted. Nothing is rewritten: the certificate hashes the bytes you sent. - Deleting. A file an open envelope still uses cannot be deleted (STORED_FILE_IN_USE). One that only finished envelopes used can; its bytes are kept for their evidence bundle until their retention ends. Unused files expire like any stored file. - The certificate says whose it is. A source you uploaded was not produced by this service, so the certificate and the evidence manifest list it as uploaded by the customer, with its hash. - The console has the same: Signatures → New signature request uploads or picks a PDF and sends it, with no API key. ### Where the signer signs The signing experience is hosted by DoConure at a secure public URL: the signer follows a link to /sign/… on our domain. That is the whole product today. There is no embedded or iframe signing and no SDK to put the page inside your own site, and it is not promised: it is a possible future evolution that has not been designed, not a feature on a roadmap. (The page refuses to be framed on purpose.) ### Test envelopes settings.isTest: true (needs the isTest token scope) makes a test envelope: it spends the shared daily test allowance and never credit; every mail about it carries [TEST] in the subject and opens with a red warning line (“TEST — this document has no legal value”, in the envelope’s language, italian or english); the signing page, the signed file (a red TEST band and tiled text on every page, signature block and certificate) say TEST and that it has no legal value; and it can never reach a paid third party. Asking for an SMS code, a seal or a timestamp together with isTest is refused with FEATURE_NOT_AVAILABLE_IN_TEST_MODE before anything is created. Read it back as output.sign.isTest. ### The request: signers and settings The body is { signers, settings } — the same shape as Generating a document's { data, settings }. signers is an object keyed by a label you choose ("A", "B", …) — it identifies each signer for you, and carries no ordering by itself: ``` POST /api/v1/sign/envelopes Authorization: Bearer lpdc_... { "signers": { "A": { "email": "anna@acme.example", "fullName": "Anna Rossi", "validationMode": "sign", "fields": [ { "kind": "signature", "page": 1, "x": 0.1, "y": 0.78, "width": 0.32, "height": 0.09 } ] } }, "settings": { "documentId": "9b21...", "subject": "Service agreement 2026", "hasSigningOrder": false, "expirationDate": null, "reminderFrequencyHours": 0, "cc": [], "ccNotifyOn": "none", "bcc": [], "bccNotifyOn": "none", "language": "en", "callbackUrl": { "onSuccess": null, "onError": null } } } ``` | Setting | Default | Means | | subject | your organisation's default subject | What the request is about, up to 200 characters: the mail's subject, and named again in its text. Required, unless your organisation set a default in Settings. | | message | your organisation's default message, or none | Your own words in the request mail, up to 2,000 characters, placed above the signing button. Line breaks and **bold** are understood: Writing the message. | | hasSigningOrder | false | false: every signer is one group, notified together. true: each signer's own order (a whole number, required only when this is true) gates them — two signers sharing a number sign as a group, and the next group is told only once this one is entirely done, or once one of them has if the group's groupMode is "OR". There is no separate "sequential" value: ordinary sequential signing is just every signer given a different number. At most 5 groups of at most 4 signers each — full reference and the OR shape: Ask for signatures in a specific order. | | expirationDate | your organisation's default (30 days) | A specific instant, strictly in the future, at most one year out. Left out, the envelope expires after your organisation's default expiry, 30 days unless you changed it in Settings. The invitation and every reminder say the day, time and time zone the link stops working, in your organisation's locale; the API answers in UTC. There is deliberately no "never" option — past that ceiling the evidence around who held a mailbox has decayed enough that re-sending is the honest move, not extending. | | reminderFrequencyHours | your organisation's default (48) | 0 turns reminders off. Above zero, whoever must currently sign is nudged every this many hours from when they were notified. A reminder reissues that signer's link, the same as a resend does, because the plaintext of an old link is never kept anywhere to send again. | | cc / bcc | [] | Plain addresses that are told about the envelope but never asked to sign. Full reference: Cc and bcc on an envelope. | | language | your organisation's language | "en" or "it". Selects which language every mail this envelope sends is written in, and which of your two branded footer texts it carries. | | callbackUrl.onSuccess / onError / onEvent | null | Where we POST when the envelope closes — completed to the first, declined/expired/cancelled to the second. Must be https and resolve to a public address. Each request carries X-Signer-Signature: sha256= over the raw body, keyed with your tenant's own secret — verify it. The body also carries an event block whose id is stable across retries, so a repeated delivery can be ignored rather than processed twice — see Tracing a call. Left null, nothing is sent and polling works exactly as before. With callbackUrl.env, the two values are paths appended to that callback environment's base URL and the callback carries its authentication (Bearer token, API key or OAuth2); paths without env go to your default environment — callback environments. onEvent is optional and separate: it receives recipient.replaced when a signer is replaced, signed and retried like the other two, which keep saying only how the envelope ended (Being told when a signer is replaced). | #### Writing the message The message is plain text, the same in the API, in a flow's signature and in the Default message of Settings (used when a request does not send one). Every mail goes out twice in one: an HTML part, and a plain-text part for the mail programs that show only text. What you write becomes both: | You write | HTML part | Text part | | **clause 4** | clause 4, in bold | clause 4, without the asterisks | | a line break (\n in the JSON) | a new line | a new line | | an empty line (\n\n) | a new paragraph | an empty line | | https://acme.example/terms | the address, written out and clickable (a full stop right after it ends the sentence, not the link) | the address | | , [text](url), *one* | shown exactly as typed | shown exactly as typed | - Bold is two asterisks on each side, on one line, at most 60 characters, with no space just inside the marks: **by 30 June** is bold, ** by 30 June ** is printed as typed. It is the only formatting there is: no italics, colours or fonts, and no HTML. - A link is shown as its address; there is no way to give it other words. Keep it inside a sentence (The terms are at https://acme.example/terms.): a paragraph that is only an address is drawn as a button, and the mail already has one, the signing button. - Do not indent every line of a paragraph: an indented block is shown as a code, in a box. ``` "message": "Please read **clause 4** before signing.\n\nQuestions? Write to legal@acme.example." ``` The ** marks belong to mail messages only. In a document, bold comes from the template itself: Writing a template. Each signer also takes a validationMode: "sign" (default — a mark plus an explicit slide-to-confirm gesture, no code at all), "email" (a one-time code emailed to the signer, the flow this product started with), or "sms" (a one-time code by text message — requires a "phone" field on that signer). An SMS code is a service your organisation buys, each SMS the gateway accepts costs two credits, and where it cannot be delivered the envelope is refused at creation with SMS_VALIDATION_UNAVAILABLE: How do I choose how a signer proves it's them? Each signer may also declare a signatureMeaning — what their signature means, which 21 CFR Part 11 §11.50(c) asks a signed record to state alongside the name and the time. One of "approved", "reviewed", "authored", "verified" or "responsibility_accepted"; free text is refused, because a meaning nobody can compare against anything is not one. It is per signer, not per envelope: on a real document one person approves and another acknowledges. Omit it and nothing is printed. That is deliberate rather than a default worth setting: most documents are not signed under a regulated procedure, and stamping “Approved” on one nobody said that about would invent the very fact the rule exists to record. When it is set it appears under the mark, on the certificate of completion, and in the signatureMeaning field of every recipient the API and the evidence bundle return. ### Retrying safely Send an Idempotency-Key on every POST in this API — create, send, resend, replace, cancel. Send is the one that matters most: send is the billable moment, so a retry after a timeout charges you again and emails every signer a second time — the cost lands on your invoice and on your counterparty’s patience at once. ``` POST /api/v1/sign/envelopes/7a04…/send Authorization: Bearer lpdc_… Idempotency-Key: contract-2026-114 ``` The envelope’s own state is a second guard — sending an already-sent envelope is refused — but that refusal is an error, and a caller retrying a request it never got an answer to deserves the answer rather than a complaint. With a key it gets the answer. Drafting takes one too. Nothing is charged for a draft, and a retried one still leaves a second envelope behind that somebody has to clean up by hand. So does resending: a resend is not a second signature and costs nothing, but a signer who gets the same link twice thirty seconds apart is being confused for no reason. And so does replacing a signer: a retried replace that was not recognised would be refused, because the slot is no longer the old person's. And cancelling, which 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. The full rules, the three outcome codes and the 24-hour window: Retrying safely. ### The response Every call in this API — create, send, resend, cancel, read — answers the same envelope, with the envelope itself under output.sign: ``` { "output": { "status": "pending", "compose": null, "sign": { "envelopeId": "7a04...", "status": "sent", "level": "SES", "seal": false, "timestamp": false, "allowDelegation": false, "expiresAt": "2027-09-20T20:32:43Z", "documentId": "9b21...", "sourceSha256": "cdc82a...", "signedSha256": null, "signedDocumentUrl": null, "padesProfile": null, "recipients": [ { "id": "c81e...", "email": "anna@acme.example", "status": "notified", "order": 0, "validationMode": "sign", "signedAt": null } ] }, "flow": null, "credits": { "used": 15, "left": 1232 }, "warnings": [], "error": null } } ``` | Key | Meaning | | output.status | "pending" while the envelope can still change, "succeeded" once every signer has signed, "failed" if it closed without that — declined, cancelled or expired. Poll on this; output.sign.status is the seven-word vocabulary underneath it. | | output.sign.envelopeId | The id every other call in this API takes. Named to match output.compose.documentId and output.flow.flowId, so that a flow's answer — which carries all three — never leaves you wondering which id is which. | | seal, timestamp, allowDelegation, expiresAt | Your settings, settled. Each is gated or capped by the organisation's own configuration, so an accepted request does not imply the value you sent. That is why there is no separate settings echo here: this is it. | | output.credits | used is what this envelope has settled — the signature, the seal and the timestamp when you send it, then each SMS code as it leaves, so the read that finds it closed carries the definitive figure; items lists it line by line; left is your balance from the ledger now. Creating a draft charges nothing (used: 0). See Credits in every answer. | | output.warnings | Where a gate that overruled you says so, with a stable code. One array per call — see Warnings. | | recipients | What became of each person. You send them as signers, because in your request everybody signs; they come back as recipients, because this product will grow approvers and viewers that do not. | GET /api/v1/sign/envelopes answers { "envelopes": [ … ], "count": n }, where each item is exactly the object above — a collection is a collection of answers, so you parse one shape rather than two. Narrow it with ?status=, ?limit= (up to 200) and ?kind=production or ?kind=test: test and production are never one list unless you ask for all (the default, as for any value it does not know). The same kind works on GET /api/v1/flow. Breaking change, 2026-09-20. These responses used to be the envelope flat at the root, with id rather than envelopeId and a warnings array inside the envelope. The keys moved; they were not duplicated. The same release gave /api/v1/compose and /api/v1/flow the identical envelope, which is the point: the section is named after the URL you called. ### When the person you asked is not the right person A signer can pass the request on from their own link. Theirs stops working immediately, the new person is sent one, and the certificate of completion names who handed it over — a document signed by somebody the sender never named is precisely the fact a dispute turns on, so it is stated in words rather than left in a table. It is off unless the organisation turns it on, in Settings. With it on, a request may ask for less: ``` "settings": { "allowDelegation": false } ``` The organisation's setting is a ceiling, not a default. If it forbids delegation, asking for it does not fail the call — the envelope is created without it and the answer carries DELEGATION_NOT_ENABLED. Read allowDelegation back from the response rather than assuming what you sent was used. Once a slot has changed hands, the recipient in the response carries delegatedFrom with the previous holder, when it happened and why. A poller that only reads email would otherwise report the wrong person as the signer. Refused, each for its own reason: passing it back to the same address; passing it to somebody already signing this document, which would ask them to sign twice and, in an ordered envelope, make them wait for themselves; and more than three hops, because each one kills a link and sends an email. Where a field's coordinates go: Where the signature goes on the page. What a signer sees, and the evidence trail: What the signer sees. Legal status of what this produces: Signature levels: SES, AES, QES. ## Cc and bcc on an envelope Source: https://app.doconure.com/docs/signing-cc-bcc Who else receives what, and when. Including the archive address your organisation can add to every envelope. ## Cc and bcc on an envelope cc / bcc in settings are plain addresses that are told about the envelope but never asked to sign — no link, no token: ``` "settings": { "documentId": "...", "subject": "...", "cc": ["sales@acme.example"], "ccNotifyOn": "completion" } ``` ccNotifyOn / bccNotifyOn is "none" (default), "completion" (one email, with the signed PDF attached), or "everything" (also the initial send and every reminder). Every address on either list gets a mail of its own, so nobody sees the other addresses: cc and bcc behave the same, and both are kept so a request written for another service still fits. Separately from cc/bcc, your organisation is told when an envelope completes, at the addresses set in Settings (Notifications), unless that notice is switched off there. Recipe form: Keep someone in the loop without asking them to sign. Full envelope settings: Signing on its own. ## Where the signature goes on the page Source: https://app.doconure.com/docs/signing-fields Place a signature with a tag in the document ({{ signature.A }}) or by page and fractional coordinates; read x and y off a PDF; fields across one document or several. ## Where the signature goes on the page There are two ways to say where a signer signs. Put a tag in the document (the usual way): the signature lands where the tag is, wherever the text around it pushes it. Or give coordinates in the request. If you send both for a signer, the coordinates win. ### With a tag in the document In the template, a tag is written like every other field, with signature and the key the signer has in signers: ``` Signed for the customer: {{ signature.A }} Name: {{ signature.A.name }} Date: {{ signature.A.date }} Signed for the supplier: {{ signature.B.box(0.3, 0.08) }} ``` | Tag | Places | | {{ signature.A }} | signer A's signature | | {{ signature.A.name }} | their name, printed when they sign | | {{ signature.A.date }} | the date they sign | | .box(width, height) | the size, in fractions of the page (default 0.25 × 0.06), on any of the three | The top-left corner of the signature is where the tag starts. The tag itself prints nothing: when the PDF is made it is found, turned into a field and removed from the file before the file is hashed, sealed or stored, so no hidden text is left for anybody to copy out of it. A tag works in Word and HTML templates in the body, a table cell, a header or a footer; in Excel in a cell of a sheet (not in the printed header or footer), one tag per cell, placed where the cell's text starts, so give it a cell of its own. Write it in one go: Word can split a tag you edit afterwards (Common mistakes). - Several tags for one signer make several fields: a signature on every page, or the date beside each one. At most 20 tags per signer, and 200 in one document. - A signer with no fields in the request is placed by their tags. A signer with fields keeps them. - Refused, never guessed: a tag for a signer the request does not have (SIGNATURE_TAG_UNKNOWN_SIGNER), and, when the template uses tags, a signer with neither a tag nor fields (SIGNER_NOT_PLACED). In POST /api/v1/flow both are refused before the document is composed, so they cost nothing — except for tags typed in an uploaded annex, which are read once the document exists. - A tag inside a condition that turns out false is not in the document. If that signer had no other place, the flow is refused after composing (SIGNER_NOT_PLACED, naming the document that was charged). - A box near the right or bottom edge is moved back inside the page, never shrunk. - A tag inside an existing link in a Word document is refused (a link cannot hold another). - If your data has its own field called signature, your field wins and the document has no tags. A document composed on its own (POST /api/v1/compose/documents/generate) remembers where its tags landed. Send it for signature later with its documentId and the signers without fields are placed at their tags in the same way. #### In a PDF you upload Type the same tag as text in the document, for example {{ signature.A }}, and ask for it to be read with "signatureTags": true on that document: ``` "documents": [ { "fileId": "6d1c...", "signatureTags": true } ] ``` An upload is never scanned unless you ask. The position is exact when the tag starts its own line (or sits in its own text box); inside a longer line it is estimated from the characters before it. The text stays in your file: an uploaded file is signed exactly as you sent it. ### With coordinates Field coordinates are fractions of the page, not millimetres or points, with the origin at the top left. So x: 0.1, y: 0.78 is a tenth of the way across and 78% of the way down. They are fractions so that a template re-rendered at another page size still puts the mark in the same place. ``` "signers": { "A": { "email": "anna@example.com", "fullName": "Anna Rossi", "fields": [ { "kind": "signature", "page": 2, "x": 0.1, "y": 0.78, "width": 0.3, "height": 0.08 } ] } } ``` #### How to read x and y off a PDF Open the PDF in a viewer that shows the cursor position in points (most do: Acrobat's measuring tool, or any PDF editor). An A4 page is 595 × 842 points. Say the line where the customer signs starts 70 points from the left edge and 650 points from the top: ``` x = 70 / 595 = 0.118 y = 650 / 842 = 0.772 ``` If your viewer measures from the bottom (PDF's own convention), subtract first: a point 192 from the bottom is 842 − 192 = 650 from the top. Width and height work the same way: a box 180 points wide is 180 / 595 = 0.30. A field must stay on the page: x + width and y + height are each at most 1. ### Fields that ask a question Besides the marks (signature, name, date), a field can be an input the signer answers on the signing page: text, checkbox or dropdown. The answer is printed in the field's own box, recorded on the signed event of the evidence trail, listed under the signer on the certificate and returned in the API (recipients[].fields[].answer). ``` { "kind": "checkbox", "page": 2, "x": 0.1, "y": 0.60, "label": "I have read annex B", "required": true } { "kind": "dropdown", "page": 2, "x": 0.1, "y": 0.68, "width": 0.4, "label": "Role", "options": ["Director", "Employee", "Contractor"], "value": "Employee" } { "kind": "text", "page": 2, "x": 0.1, "y": 0.76, "width": 0.5, "label": "Tax code", "required": false } ``` | Key | Means | | label | The question the signer sees. Required for an input. | | required | Default true. The signature is refused (SIGNER_FIELD_INVALID) until it is answered; a required checkbox must be ticked. false lets it stay empty, and then it prints nothing. | | options | Dropdown only: 2 to 20 choices, each up to 80 characters, all different. Any other answer is refused. | | value | What you prefilled. The signer may change it… | | locked | …unless true: then the answer is your value whatever the form says. A locked field needs a value. | None of these mean anything on a signature, name or date, and a request that sets one there is refused, never ignored. The signing page works with script switched off: the questions are plain form controls in the one linear form. ### When the envelope has more than one document Up to ten documents can go into one envelope. They are merged, in the order you list them, into a single PDF before anybody signs — so there is one set of marks, one certificate of completion, one hash on /verify, and the signer reads one file. ``` "settings": { "documents": [ { "documentId": "contract-9f2a..." }, { "fileId": "6d1c..." } ], "subject": "Service agreement" } ``` documentId on its own still works and means exactly what it always did; use documents when there is more than one. A field then says which document and which page of that document: ``` { "kind": "signature", "document": 2, "page": 1, "x": 0.1, "y": 0.8, "width": 0.3, "height": 0.08 } ``` Never an absolute page of the merged packet. You cannot know how long our merge made the file, and the first time an annexe gains a page every signature after it would move — silently. The offset is ours to know, so it is ours to apply. document defaults to 1, so an envelope written before this existed means exactly what it always meant. Two things are refused rather than accepted quietly: a field on a document the envelope does not have, and a field past the last page of its own document. The second used to be accepted and the mark dropped at signing time, which is a mark nobody ever sees and nobody is told about. Once it is signed, the certificate of completion lists every source with its own hash, and the evidence bundle carries each one separately beside the packet. The packet has replaced its parts, so without that, "which documents were in this" stops being answerable. Where this sits in the request body: Signing on its own. ## What the signer sees Source: https://app.doconure.com/docs/signing-evidence The signing page, the legal qualification printed on it, and the certificate of completion appended to the finished PDF. ## What the signer sees An email with a link. What the link's page asks for depends on validationMode: in "sign" mode, a typed name, a drawn stroke, or a preset style, plus a slide-to-confirm gesture and an explicit consent tick — no code. In "email" mode, the same mark options plus a one-time code sent to the signer's own address: six digits, ten minutes, five guesses, and a fresh code resets the guesses. When the last signer (or, in an ordered envelope, the last group) is done, the marks are composited onto the PDF, a certificate of completion is appended — every signer, how each was verified, timestamp, IP address, consent and the full evidence trail — and the finished file is fingerprinted with SHA-256. The page is hosted by DoConure at a secure public URL; the signer's browser opens it on our domain. It is not embedded in your site, there is no iframe or SDK, and embedding is not promised — it is a possible future evolution, not a plan. Any questions you put on the document (text, checkbox, dropdown) appear on the same page, and a test envelope is stamped TEST on the page, in the signature block and on the certificate. The page speaks the envelope's language (Italian or English), works on a phone, and works without JavaScript: the signer types their name as the mark and the consent tick is the whole confirmation. A link that stopped working before it was used — the request expired, was revoked, or the signer was replaced — opens a page that says which, in the envelope's language, and nothing else. Times on the certificate of completion are written in your organisation's locale, with the UTC instant beside them. The line printed under each signature (Signed by …) and a date field are in that locale too, the zone named. Anybody can check a file at /verify by uploading it: the page re-computes the fingerprint and says whether it matches, in Italian or English. A single changed byte does not match — see Check a signed file later. ### They can also tell us it should not have been sent Every signer-facing page and every signature request we send carries a Report this document link to /report. It needs no credential, because the person using it is a stranger who believes they have been sent a fraud — and before it existed their only channel was a WHOIS lookup. Worth knowing as an integrator, and it is short: a report suspends nothing. It is an anonymous assertion about a paying customer, so it goes into a queue a person reads, alongside whatever reference the reporter happened to have. Nothing the reporter writes reaches you, and we never confirm to them that a reference matched anything of yours. If we ever act on one you hear from a human being, not from an API error. If a legitimate document of yours is being reported, that is usually worth knowing on its own: it means the covering message did not make clear who was asking and why. Validation modes in full: Signing on its own. Legal status of the mark and the trail behind it: Signature levels: SES, AES, QES. ## Signature levels: SES, AES, QES Source: https://app.doconure.com/docs/signature-levels What we produce (SES, optionally sealed and timestamped), what we refuse and why, and what eIDAS actually requires for each level. ## Signature levels: SES, AES, QES This is SES, and only SES. AES and QES are coming soon, in the next wave of the product. A simple electronic signature under Regulation (EU) 910/2014: admissible, with the evidence trail described in What the signer sees to support it, and not an advanced (AES) or qualified (QES) signature. No qualified certificate belonging to the signer is involved, and nothing about who signed is cryptographically attested. Until they arrive, asking for "level": "AES" or "QES" is refused with SIGNATURE_LEVEL_UNAVAILABLE, never quietly served as SES. Under eIDAS those are three different legal objects — at QES the burden of proof reverses — so being handed the weaker one while believing you hold the stronger is the one failure this service will not produce. Each level is also something your organisation holds. SES is included with the signature product; if a call asks for a level your organisation does not hold, it is refused with FEATURE_NOT_ENABLED and details.feature set to signatureSes, signatureAes or signatureQes — the platform’s own “coming soon” for AES and QES is answered first and is a different sentence. GET /api/v1/usage lists what you hold. ### The seal is a different thing from the signature A signed document can also carry a qualified electronic seal and a qualified timestamp. Both are available (2 credits, 6 with the timestamp) to every organisation that is not a test organisation; the seal says the file came from DoConure and is unaltered, not who signed (Qualified seal and qualified timestamp). Neither changes the level above, and the confusion between the two is worth spending a paragraph on, because the document you are comparing us against probably has exactly this shape. | | Whose act it is | What it asserts | | Signature | a natural person | this person agreed. SES: admissible, and if challenged the evidence trail is what supports it | | Seal | a legal person — us, the service | these bytes left this organisation in this form and nobody has touched them since (eIDAS Art. 35). It says nothing whatever about who signed | | Timestamp | an independent authority | these bytes existed at this hour (eIDAS Art. 41) | What a seal buys is that the proof stops depending on us. Today the claim "this is the document that was signed" is a hash in our database — sound, and it is us vouching for our own record. A sealed PDF makes the same claim verifiable in Acrobat, offline, by someone who has never heard of us, for as long as the file exists. #### Two switches, and what each combination produces | Asked for | PAdES profile | What it is worth | | neither | null | The ordinary signed PDF. Verify it through our /verify page. | | timestamp only | — | Refused with TIMESTAMP_REQUIRES_SEAL (1012). The timestamp is an option of the seal and is never applied on its own. | | seal only | B-B | Origin, until our sealing certificate expires — after that the signature can no longer be validated. Cheapest, and the one with an end date. | | both | B-T / B-LT | Origin and time. B-LT embeds the validation material, so it keeps verifying after the certificate expires. This is what you want for anything you must still be able to prove in ten years. | Read padesProfile from the completed envelope. It is reported rather than implied, precisely because B-B and B-LT do not age the same way and the difference is invisible when the file is new. Once they are offered, you will send "seal": true and/or "timestamp": true in the envelope's settings. Both are optional and tri-state: omit them and the organisation's own setting decides. Until then leave them out (or send false): true is refused as above. Where level is set: Compose and sign, in one call or Signing on its own. Full error table: Outcomes and error codes. ## Qualified seal and qualified timestamp Source: https://app.doconure.com/docs/trust-services Ask for a qualified electronic seal or a qualified timestamp on any document, on any of the three APIs. What each one proves, what it costs and what comes back when an envelope is cancelled or the seal fails, what happens when your organisation has not bought it, and why a failure withholds the document rather than returning it unsealed. ## Qualified seal and qualified timestamp Two things a finished document can carry that let it prove itself without us. They work on all three APIs — composition, signature and the one-call flow — and they are bought separately from the products themselves. | | What it proves | Who says so | | qeSeal | these bytes left this organisation in this form and nobody has touched them since (eIDAS Art. 35) | us, with a qualified certificate a QTSP issued | | qualifiedTimestamp | these exact bytes existed at this hour (eIDAS Art. 41) | an independent timestamping authority | Neither one changes the signature level. A seal is the act of a legal person and says nothing about who signed; the signature stays SES either way. See Signature levels — the distinction matters, and a document you are comparing us against probably blurs it. Available now, to every organisation that is not a test organisation. A seal is 2 credits, a seal with its qualified timestamp 6 (the timestamp is 4 and is never sold alone). Gift credit pays for them like any other credit. An organisation that registers itself holds both by default; one opened by an operator holds them when the operator turns them on. They are asked for per call (qeSeal, qualifiedTimestamp) or by your organisation's own setting. What they say, and what they do not. The seal is applied with DoConure's organisational certificate, not one in your organisation's name. It says the file came from DoConure and has not been altered since; it does not say who signed (that is the signature, which stays SES). With the timestamp the file is PAdES B-LT: it keeps validating after the certificate expires. What comes back. Paid first, and what a problem leaves unused returns to the credit it came from. An envelope pays its seal and timestamp with the signature at the send; if it is cancelled, expires or is declined, they are returned (the signature is not); if they cannot be applied when the last signer finishes, they are returned at once, and so are they when the provider received the request and its answer was lost (we never repeat it). Each return is a line in your audit trail with its code (ATTESTATION_REFUNDED_*, 2130 to 2132). Servono un fornitore esterno configurato per la piattaforma, la funzione abilitata per la tua organizzazione e una configurazione di produzione valida. Finché non ci sono tutti e tre, una richiesta che ne chiede uno viene rifiutata con TRUST_PROVIDER_NOT_CONFIGURED o FEATURE_NOT_ENABLED, mai soddisfatta con un documento non sigillato. Un fornitore simulato è indicato come non qualificato. ### Asking for one Both are optional in settings and tri-state: true asks for it, false refuses it, and leaving the key out hands the decision to your organisation's own setting (see the table below). Sending false applies nothing and charges nothing. ``` POST /api/v1/compose/documents/generate Authorization: Bearer lpdc_… { "data": { "cliente": "Acme S.r.l." }, "settings": { "templateId": "contract", "qeSeal": true, "qualifiedTimestamp": true } } ``` On the signature API the same two capabilities have been called seal and timestamp since 2026-09-18, and they still are — a published field is permanent here. qeSeal and qualifiedTimestamp are accepted there too, as aliases, so one vocabulary works across all three surfaces and nothing already written breaks. ``` POST /api/v1/sign/envelopes { "signers": { … }, "settings": { "documentId": "…", "subject": "Service agreement", "qeSeal": true } } ``` On an envelope the two are recorded as an intent and acted on when the last signer finishes, which may be days later. On a composition they are applied inside the call. ### The four combinations, and what each produces The timestamp is an option of the seal, not a product of its own: you can have the seal without it, never the other way round. A seal is a fixed annual certificate and a timestamp costs money every time, so buying origin without buying time is a position a customer is entitled to take — and the PAdES profile that results is reported back in output.compose.trust.padesProfile so nobody discovers in 2031 that they bought a seal and not ten years. | qeSeal | qualifiedTimestamp | Profile | What it is worth | | false | false | null | an ordinary PDF | | false | true | — | refused with TIMESTAMP_REQUIRES_SEAL (1012): the timestamp is an option of the seal and is never applied on its own — see below | | true | false | B-B | origin, until the certificate expires — after which it cannot be validated | | true | true | B-T or B-LT | origin and time. B-LT keeps validating after the certificate expires | What they cost. A seal applied by a qualified provider is 2 credits and its qualified timestamp 4 (6 together), paid at the send for an envelope and with the document for a composition. A seal or timestamp from a provider that is not qualified (a test or simulated one, reported with TRUST_NOT_QUALIFIED and "qualified": false) costs nothing: it attests nothing, so it is not sold. The dry run's estimatedCredits follows the same rule. What is returned when one is not applied is described at the top of this page. ### Your organisation has to hold the capability These are sold separately from the three products. An organisation that registered itself holds both from the start; for one an operator opened, we turn them on. Your administrator can see which ones are active under Settings → Trust and compliance in the console; only we can change them. A request that asks for one you do not hold is refused: ``` 403 Forbidden { "code": "FEATURE_NOT_ENABLED", "number": 1007, "message": "Qualified electronic seal is not enabled for this organisation…", "details": { "feature": "qeSeal", "enabled": { "qeSeal": false, "qualifiedTimestamp": true }, "note": "the request was refused, not downgraded" }, "retryable": false } ``` Refused on every API, and never quietly produced without it. That is the same reasoning as SIGNATURE_LEVEL_UNAVAILABLE: being handed a document you believe is sealed and is not is worse than being told no. Nothing is charged, and details.enabled tells you what you do hold, so an integration can adapt without a support call. ### Your own settings are a separate, lower ceiling Once a capability is active, your organisation still chooses when it applies, under Settings → Document authenticity. Three positions, and they behave the way every other gate in this API does: | | you ask true | you ask false | you say nothing | | off | no, with a warning | no | no | | optional | yes | no | no | | always | yes | yes, with a warning | yes | Unlike the entitlement above, this one warns rather than refusing — you receive the ordinary document plus a SEAL_NOT_ENABLED or TIMESTAMP_NOT_ENABLED warning. The difference is that your own administrator chose this ceiling and can see it on their own settings page, so handing back less is something you can find out about and act on. Always read the settled value, never your own request. The response echoes what actually happened: ``` { "settings": { "qeSeal": true, "qualifiedTimestamp": false, … }, "output": { "compose": { "trust": { "qeSeal": { "requested": true, "applied": true, "provider": "local", "appliedAt": "2026-09-23T14:02:11+00:00" }, "qualifiedTimestamp": { "requested": true, "applied": false }, "padesProfile": "B-B" } }, "warnings": [ { "code": "TIMESTAMP_NOT_ENABLED", "number": 2002, … } ] } } ``` ### A failure withholds the document If you asked for a seal and it could not be applied, the composition fails — 502 with SEAL_FAILED or TIMESTAMP_FAILED — and no document comes back. Nothing is charged for the attestation. That is deliberate. A composition can be run again, so withholding costs you a retry; handing back an unsealed PDF where a qualified seal was asked for would be claiming a property the file does not have, which is the one thing this service will not do. Neither is marked retryable, and that is also deliberate: the provider may have performed the operation before the connection failed, and a blind retry of a paid operation is how one document costs two. Where the failure is clearly transient — the provider could not be reached at all, a rate limit, a timestamp authority that did not answer — you get TRUST_PROVIDER_UNAVAILABLE instead, which is retryable, with details.category saying why. The line between the two is whether the provider received the request. A seal request that timed out after it was sent comes back as SEAL_FAILED with details.category: "OUTCOME_UNKNOWN" — we do not know whether it was performed, so we never repeat it on your behalf, and we record the provider's reference whenever we learned one so the operation can be reconciled afterwards. A signature that has been given is never lost to this. An envelope behaves the opposite way on purpose: if the seal fails at completion, the envelope still completes, with SEAL_NOT_APPLIED in its warnings and padesProfile: null recorded. A person went through the whole signing flow; stranding their document because somebody else's endpoint was restarting would punish them for an outage that is not theirs, for an attestation that can be obtained again by other means. ### Test organisations never buy either one An organisation marked as scaffolding gets neither, whatever its settings say, and the response carries PAID_EXTRA_SKIPPED_ON_TEST so you can see why the profile came back null. Use the live organisation to see a sealed document. The same goes for a single call: isTest: true together with qeSeal: true or qualifiedTimestamp: true is refused with FEATURE_NOT_AVAILABLE_IN_TEST_MODE before anything is reserved or sent to a provider. ### A timestamp on its own is refused The qualified timestamp exists only as an option of the seal. Asking for qualifiedTimestamp: true without qeSeal: true is refused with TIMESTAMP_REQUIRES_SEAL (1012, HTTP 422, not retryable), on every door — compose, flow, a batch, an envelope — before anything is rendered, reserved, sent or charged. A timestamp by itself would come from an authority that is not necessarily qualified and would read as something it is not; a sealed document without a timestamp is fine. ``` 422 Unprocessable Entity { "code": "TIMESTAMP_REQUIRES_SEAL", "number": 1012, "message": "a qualified timestamp is an option of the qualified seal and is never applied on its own: …", "details": { "feature": "qualifiedTimestamp", "requires": "qeSeal", "source": "request" }, "retryable": false } ``` details.source is request when you asked for the timestamp, and organisation setting when your organisation's own timestamp_mode is always and the document is not sealed: set Seal to always as well, or the timestamp to optional. An envelope that loses its seal before it completes finishes without either, and says so in two warnings (SEAL_NOT_APPLIED, TIMESTAMP_NOT_APPLIED). ### Who performs them A qualified seal and a qualified timestamp are DoConure capabilities. Which QTSP and which timestamping authority are behind them is ours to change — the request you write, the errors you branch on and the metadata you read back do not change when one is replaced. output.compose.trust.*.provider carries our own internal name for whoever performed it, so a support conversation has something to quote. Where a deployment is running against a simulated authority — the whole path runs, and the token attests nothing to anybody — it says so, on every screen and in capabilities, and qualified is reported as false whatever the configuration claims. ## Managing templates from a script Source: https://app.doconure.com/docs/templates-api Create, version, activate and deactivate templates over the API, so a deploy can ship a template the way it ships code. ## Managing templates from a script Everything the console's Templates page does is also a call on the API, apart from renaming, so an integration can onboard itself — upload a template, roll a new version forward, activate it — without anyone opening the console. A template is renamed in the console (the template's page, Name): only the name people read changes, the templateId your code calls stays the same. The file travels as base64 inside the JSON body, the same shape as every other endpoint here, rather than multipart: nicer for callers like Salesforce Apex that have no pleasant multipart story. | Method | Path | Does | | POST | /api/v1/compose/templates | Create a template + version 1, active immediately. | | GET | /api/v1/compose/templates | List this tenant's templates. | | GET | /api/v1/compose/templates/{templateId} | One template, with every version. | | POST | /api/v1/compose/templates/{templateId}/versions | Add a version. Arrives inactive — same rule as the console. | | POST | /api/v1/compose/templates/{templateId}/versions/{n}/activate | Activate version n. Others stay as they are. | | POST | /api/v1/compose/templates/{templateId}/versions/{n}/deactivate | Switch version n off; the rest keep working. | | POST | /api/v1/compose/templates/{templateId}/deactivate | Switch the whole template off, every version with it. | ``` POST /api/v1/compose/templates { "name": "Invoice", "templateType": "docx", "fileName": "invoice.docx", "fileBase64": "UEsDBBQAAAAIA...", "errorOnMissingFields": false } ``` Every one of these answers with the template's full current state: ``` { "templateId": "invoice-a1b2c3", "name": "Invoice", "templateType": "docx", "active": true, "errorOnMissingFields": false, "payloadValidation": "warn", "activeVersions": [1], "versions": [ { "versionNumber": 1, "active": true, "originalFilename": "invoice.docx", "checksum": "9f8c...", "createdAt": "2026-09-14T10:00:00+00:00", "hasJsonSchema": false } ] } ``` Validated at upload, not at the first generate. These endpoints check the file immediately — the same check the console runs when a file is uploaded there — and answer TEMPLATE_FILE_REJECTED or TEMPLATE_FILE_INVALID on the upload call itself — an unattended integration has no page to notice the problem on later. templateType is fixed at creation (docx, html or xlsx); every later version of that template must be the same format. Template ids are assigned by the service, the same slug-plus-suffix scheme the admin uses — do not try to choose one. ### Checking the data: JSON Schema A version can carry a jsonSchema (on the upload above, on a new version, or pasted in the console). Every compose call that uses that version then has its data measured against it. What a violation does is the template's payloadValidation: | Mode | What happens | | warn (default) | The document is produced and charged as usual. output.warnings carries PAYLOAD_SCHEMA_VIOLATIONS (2106) listing the first 20 violations. | | reject | The call is refused with PAYLOAD_SCHEMA_VIOLATION (1403, 422) before any credit, test item or render slot is taken: it costs nothing and is never recorded as the result of an Idempotency-Key. In a bulk batch the item fails with that code and is not charged; the rest go on. In a flow, nothing is composed or sent. | | off | The schema is ignored. | Set the mode with payloadValidation when you create the template, or on a new version together with its schema, so a CI job can ship both in one call and switch to reject: ``` POST /api/v1/compose/templates/{templateId}/versions { "fileName": "invoice.docx", "fileBase64": "UEsDBBQAAAAIA...", "jsonSchema": { "type": "object", "required": ["cliente"], "properties": { "cliente": { "type": "string", "maxLength": 40 }, "importo": { "type": "number", "minimum": 0 } }, "additionalProperties": false }, "payloadValidation": "reject" } ``` A violation is {path, keyword, message}: path is a JSON pointer into your data (/righe/1/qta; empty means the whole payload), keyword the schema keyword that failed, message a sentence made of the schema's own words and the type found. The value you sent is never in it, so the warning is the same on a dataless organisation. A key name that came from your data (under additionalProperties) is hidden there too. The schema is customer input and is treated like a template: it is code. The service enforces a bounded subset of draft 2020-12 and refuses the rest at upload with JSON_SCHEMA_INVALID (1303), so a schema either does what it says or is not accepted. - Supported: type, enum, const, minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, minLength, maxLength, pattern, minItems, maxItems, uniqueItems, items (one schema), required, properties, additionalProperties, minProperties, maxProperties, allOf, anyOf, oneOf, not, and $ref to # or #/$defs/name. - Accepted and ignored: $schema, $id, $comment, title, description, default, examples, deprecated, readOnly, writeOnly and format (never asserted). - Refused: everything else (patternProperties, prefixItems, if/then/else, dependentRequired, unevaluated*), and any $ref that is not local: nothing is ever fetched. - Limits: 64 KB of schema, 24 levels, 1,000 sub-schemas, 500 enum values; a pattern of at most 128 characters, no back-references or look-arounds, no repeated group that itself repeats, at most two unbounded repeats, and a string over 2,048 characters is not matched against a pattern. Checking has a step and time budget; if it runs out the answer is a violation with keyword $budget, never "valid". ### Retrying an upload Every POST on this page accepts an Idempotency-Key header, and none of them charges you anything. That is not the reason to send one. A timed-out upload retried without a key leaves you with two templates of the same name and two ids, and your integration then points at whichever came back last while somebody in the console picks the other. ``` POST /api/v1/compose/templates Authorization: Bearer lpdc_… Idempotency-Key: invoice-template-v3 ``` The rules are the ones on composing a document, unchanged: the same key with the same body replays the first answer with Idempotent-Replay: true, the same key with a different body is refused, and a failed attempt is never recorded so your next one runs for real. ## Tracing a call, and watching your usage Source: https://app.doconure.com/docs/observability The request id on every response, the ordered event timeline of a flow, the one-call usage endpoint, the event id that makes a retried webhook safe to ignore, and where to fetch the OpenAPI spec. Five things you would otherwise build yourself. ## Tracing a call, and watching your usage Two things every integration eventually builds for itself, and neither of them is your product. Here they are, so you do not have to. ### Every response carries an id Every answer this service gives — the API's, and the console's pages — carries an X-Request-Id header: ``` X-Request-Id: req_9f2c6b1a4e7d40f0a1b2c3d4e5f60718 ``` Log it next to your own request. It is the only thing that turns "a call failed this morning" into a row we can look at: it is written onto the generation itself, so quoting one identifies the exact job, its template, how long it took and what it answered. On a failure you do not even have to keep the header. The same id is in the body, so an error you paste into a support message has already told us which call it was: ``` { "code": "TEMPLATE_VERSION_NOT_ACTIVE", "number": 1204, "message": "version 2 of template 'contract' is not active", "retryable": false, "requestId": "req_9f2c6b1a4e7d40f0a1b2c3d4e5f60718" } ``` The id is ours, minted per request. If you send an X-Request-Id of your own we ignore it, and that is deliberate: two of your requests carrying one id would be indistinguishable in our logs, which is the whole property the id exists to provide. Record the one we return alongside your own correlation id and you have both halves. ### One call for what you have used GET /api/v1/usage answers four questions in one request: what your balance is, what operations cost you, what you have spent, and which limits will refuse you. ``` curl https://your-host/api/v1/usage \ -H "Authorization: Bearer YOUR_API_KEY" ``` ``` { "organisation": { "id": "...", "name": "Acme", "deliveryMode": "prepaid", // deprecated "suites": { "documents": true, "signatures": true, "flow": true } }, "balance": { "credits": 4310, "overdraft": 0, "rates": { "document": 5, "signature": 10, "sms": 2, "email": 0, "seal": 1, "timestamp": 3 } }, "spent": { "windowDays": 30, "since": "...", "credits": { "document": 640, "signature": 150 }, "operations": { "document": 128, "signature": 15 } }, "thisMonth": { "since": "...", "documents": { "production": { "succeeded": 96, "failed": 2 }, "test": { "succeeded": 5, "failed": 0 } }, "documentsSucceeded": 101, "documentsFailed": 2, // deprecated "credits": { "document": 480 } }, "testItems": { "used": 5, "limit": 20, "remaining": 15, "resetsAt": "2026-10-16T00:00:00+00:00" }, "limits": { "requestsPerMinute": 30, "documentsPerMonth": null, "jsonBytes": 10485760, "templateBytes": 26214400, "assetBytes": 10485760, "maxPages": 5000, "downloadLinks": { "defaultSeconds": 3600, "maxSeconds": 172800 }, "storedFilesDays": 30 }, "asOf": "..." } ``` Everything under limits is a number that produces a refusal, in the same units the refusal uses — so you can raise an alarm before a 429 rather than after one. It carries every row of the console's Limits page: the largest JSON payload, template and uploaded file (assetBytes: an image, an attachment or a PDF to sign), pages per document, how long a download link lives by default and at most (downloadLinks: a longer request is refused, never shortened) and how many days an uploaded file is kept. balance.credits against balance.rates tells you how many more of each operation you can actually perform. It belongs to your account, not to a product. Your balance is the same fact whether you bought composition, signature or the flow — so this endpoint keeps answering even when one of the three is switched off for your organisation, which is exactly the moment you want to look at it. Production and test are never one number. thisMonth.documents counts what you generated for real under production and the isTest calls apart under test; test calls spent no credit. The two older fields, thisMonth.documentsSucceeded and documentsFailed, have always added the two together; they are kept so a client that reads them keeps working, are deprecated and removed in 2.0, and should be replaced by documents.production. testItems is today’s shared test pool: used, limit, remaining, and resetsAt, the next 00:00 UTC (the pool resets once a day, at a fixed moment). The console shows the same split: lists and totals say “1,240 documents · 37 test”, with a Production / Test filter, and the dashboard says how long until the pool refills. Times in this answer are UTC: Times and time zones. Every organisation is prepaid: the balance is a budget that goes down as you spend it and is refused at zero (QUOTA_EXCEEDED). Two fields are deprecated and will be removed in 2.0, and are kept only so a client that reads them keeps working: organisation.deliveryMode is always the constant "prepaid", and limits.documentsPerMonth is always null because there is no monthly document cap any more. Do not branch on either. ### Times and time zones Every timestamp in an API answer or a webhook is UTC, ISO 8601, and never changes with your settings, so a program never has to guess a zone. What a person reads — your console, the emails, the certificate of completion and the CSV exports — is written in the locale of your organisation: its time zone and its way of writing a date (for example it-IT, Europe/Rome, 21/10/2026 14:30). We set the locale for your organisation from a fixed list; write to us to change it. It replaced the separate Time zone and Date format settings, which had no effect. - The certificate of completion states the local time and the UTC instant; an export carries the offset on every time. - The signing invitation and every reminder say the day, time and time zone the link stops working. - The line printed under each signature on the document (Signed by …) gives the time in your organisation's zone, with the zone named, and a date field carries your organisation's day. - The daily test pool refills at 00:00 UTC; the console's Limits page shows that moment in your organisation's time (02:00 in Italy in summer, 01:00 in winter) and how long is left. ### Which of your keys does what Give each integration its own key, with a name that says what uses it (billing-export, crm-sync). Every authenticated request is counted against the key that made it, so the key table in your console (API keys) shows, per key, when it was last used, how many requests it made in the last 30 days and how many of those failed. That is how you find the integration that is erroring, the key nobody uses any more (revoke it), and the one that is about to expire. What is counted is the request, its outcome and its route, never its content: no payload, no address, no key. The counters are kept per key and per day, are written every half minute or so, and are not an invoice — what a call cost is in output.credits and in your credit history. A request made with a key that is unknown, revoked or expired belongs to no key and is not counted. ### The whole lifecycle, in order GET /api/v1/flow/{flowId}/events answers the third question a flow raises. GET /api/v1/flow/{flowId} says where it is; this says how it got there. ``` { "flowId": "...", "schema": 1, "count": 7, "events": [ { "sequence": 1, "type": "composition.started", "occurredAt": "...", "recipient": null }, { "sequence": 2, "type": "composition.completed", "occurredAt": "...", "recipient": null }, { "sequence": 3, "type": "envelope.created", "occurredAt": "...", "recipient": null }, { "sequence": 4, "type": "envelope.sent", "occurredAt": "...", "recipient": null }, { "sequence": 5, "type": "recipient.viewed", "occurredAt": "...", "recipient": "anna@example.com" }, { "sequence": 6, "type": "recipient.signed", "occurredAt": "...", "recipient": "anna@example.com" }, { "sequence": 7, "type": "envelope.completed", "occurredAt": "...", "recipient": null } ] } ``` Three subjects — composition, envelope and recipient — and the second and third use the same words the callback does, so envelope.completed means one thing wherever you meet it. sequence is a position, not an identifier: it orders the list and lets you spot a gap. id is what deduplicates. Available while the flow is still open, which is the difference between this and the evidence bundle — that is the archival form of the same facts and exists only once the matter is settled, which is the one moment it is no use for debugging. ### Deduplicating a callback If you take webhook callbacks, the body carries an event block so a retry cannot be mistaken for a second signature: ``` { "envelopeId": "...", "status": "completed", "event": { "id": "evt_...", "type": "envelope.completed", "occurredAt": "2026-09-22T18:20:00+00:00", "schema": 1 } } ``` event.id is stable across retries: keep the ids you have processed and ignore one you have already seen. A receiver that answered us and then timed out will be retried, and that retry is the same event, not a new one. event.occurredAt is inside the signed body on purpose. The HMAC proves a body came from us; it does not prove when, so a captured request replayed months later still verifies. Reject anything far older than your own tolerance. The same values are repeated in X-Signer-Event-Id and X-Signer-Attempt for convenience — but the headers are not covered by the signature. Deduplicate on the body. #### Being told when a signer is replaced By default a callback says only how an envelope ended (envelope.completed, declined, expired, cancelled), at onSuccess or onError. To also hear a signer being replaced (POST .../recipients/{id}/replace), name a third address in the request's callbackUrl: ``` "callbackUrl": { "onSuccess": "https://you.example/ok", "onError": "https://you.example/ko", "onEvent": "https://you.example/event" } ``` With an environment, onEvent is a path like the other two. It is its own address, and opt-in, on purpose: a hit on onSuccess or onError has always meant “signed” or “did not sign”, and an integration written for that must never be sent something else. Without onEvent nothing is sent, and onEvent never receives how the envelope ended. The body is the envelope as GET shows it, the usual signed event block ("type": "recipient.replaced", same id across retries), the header X-Signer-Event: event, and the replacement: ``` { "envelopeId": "...", "status": "in_progress", "recipients": [ ... ], "event": { "id": "evt_...", "type": "recipient.replaced", "occurredAt": "...", "schema": 1 }, "replaced": { "recipientId": "...", "from": { "email": "anna@example.com", "fullName": "Anna Rossi" }, "to": { "email": "carla@example.com", "fullName": "Carla Bianchi" }, "reason": "Anna left the company" } } ``` Delivered like every callback: signed, retried, listed in the console's webhook log and replayable. It is the one event that arrives before the end, so do not treat it as an outcome; the envelope is still open. ### The machine contract The OpenAPI document is at /openapi.json — public, no key needed, no cookie set. We do not ship language SDKs on purpose: the API is one POST with a JSON body, and five client libraries to keep in step with a published contract would be five things to get out of date. Generate one from the spec if you want one. It describes the three product prefixes and the account-level endpoints. The older spellings — /api/v1/documents, /api/v1/templates, /api/v1/signatures — still work and are governed identically; they are simply not advertised, so a generated client has one method per call rather than two. Setting a callback up: Know when something finished · Every error code: Outcomes and error codes · What a credit costs: Credit, prices and VAT. ## Tracking, consent and the ad platforms Source: https://app.doconure.com/docs/tracking How the public site asks for consent and measures campaigns: the banner, the cookies, Google Analytics, Google Ads, Meta, LinkedIn and OpenAI, the server-side events, and how to set it up, test it in DebugView and exclude your own browser. ## Tracking, consent and the ad platforms The public site (the home page, the legal pages, the contact form, the guide and /signup) can measure visits and campaigns. It is off until an id is set, and nothing leaves the visitor’s browser until they say yes. This page is for whoever runs the campaigns. ### How it works - Consent, ours. A banner served from our own domain, in Italian (English on the English pages), with Rifiuta tutto as prominent as Accetta tutto and a panel per purpose: necessary, statistics, advertising. The choice is kept six months in a first-party cookie; a link in every footer reopens it. Google Consent Mode v2 starts with every signal denied and is updated on the choice. - No request to a third party before a consent. The page reads one small file from its own domain (/tracking-config.json, public ids only) to learn which channels are on. If none is on — or the deployment is not production — there is no banner and no tag. - Each tag loads only for its purpose. Statistics: Google Analytics 4. Advertising: Google Ads, Meta, LinkedIn, OpenAI. With a Tag Manager container set, Tag Manager loads instead of gtag.js, after the consent defaults; the same events reach its dataLayer. - The app host carries nothing. The console, the admin, the signing pages and the verification page have no banner and no tag. /signup is the one public page on the app host. ### The cookies and identifiers | Name | Set by | For | Lasts | | doconure_consent | us, the banner | remember the choice (version, statistics 0/1, advertising 0/1, time) | 6 months | | doconure_internal | us, an operator | keep an operator’s browser out of the data | 180 days | | session | us, on the app host only (sign-in and the sign-up form) | the console session and the forms’ anti-forgery token | 12 hours | | _ga, _ga_* | Google Analytics, after statistics | tell visits apart | per Google (up to 2 years) | | _gcl_* | Google Ads, after advertising | the click that led here | per Google (90 days) | | _fbp, _fbc | Meta, after advertising | the campaign click | per Meta (90 days) | | LinkedIn Insight cookies | LinkedIn, after advertising | campaign measurement | per LinkedIn | | OpenAI pixel storage | OpenAI, after advertising | campaign measurement | per OpenAI | Our own campaign record (the UTM labels, the landing page and the click id carried through the sign-up form) is separate and needs no cookie. ### Setting it up - In Settings, group Tracking and ads, enter the public ids: ga4_measurement_id (G-...), gtm_container_id (GTM-..., optional), google_ads_id (AW-...), meta_pixel_id, linkedin_partner_id (and its linkedin_conversion_rules), openai_pixel_id. Each empty one is a channel that is off. - For server-side events add the secrets to infra/doconure.env on the server (never in a form): GA4_API_SECRET, OPENAI_CONVERSIONS_API_KEY, LINKEDIN_CONVERSIONS_TOKEN. Production also sets TRACKING_ENABLED=1; staging and development never do, which is why nothing is ever sent from them. - Choose the server mode: SIMULATED (the default: events are recorded on the Tracking page, nothing is sent), VALIDATE (the provider’s validation endpoint, nothing is counted) or LIVE. - Open the Tracking page: every channel shows off, simulated, validate or live. ### Testing with GA4 DebugView - On the Tracking page choose exclude this browser and press Mark as internal. The tags load in this browser with traffic_type: internal and debug_mode. - Open the public site, accept statistics in the banner, and in Google Analytics open Admin > DebugView: your page views and events appear there. GA4’s Internal Traffic filter (set to active once, on traffic_type = internal) keeps them out of the reports. - For the server side press Validate next to Google Analytics 4: it sends one test event to /debug/mp/collect and shows Google’s answer. ### Excluding a browser No tracking at all shows no banner and loads no tag in that browser. Mark as internal loads them with traffic_type: internal. Remove returns it to an ordinary visitor. The cookie is doconure_internal, on the registrable domain, so the public site and the app host both see it. Do it once per browser. ### Server events Five events are sent from the server, never from a request: sign_up (registration verified), first_pdf, first_signature, purchase (net value, currency, transaction id; the first one also flagged first_purchase) and credit_gift_spent. They are queued with the thing they report and sent by the outbound sweep; a failed send is retried and then marked failed. Each has a stable id, so a platform can de-duplicate. The payload holds numbers and ids only: never an email, a name, a phone number, an IP address or a document. They go to Google Analytics 4, LinkedIn and OpenAI. Meta gets none: its Conversions API needs the browser’s user agent, which we never send, so Meta has only its pixel in the browser, after consent. Google Ads gets none either: its conversions leave as the CSV below. An event is skipped, with the reason on the Tracking page, when: the deployment is not production; the organisation is a test organisation; a verified address of it is on an internal domain (the registration bypass list); the visitor did not consent to the provider’s purpose (statistics for Google Analytics, advertising for the others) or came from an excluded browser; the payment was simulated; or the platform has no identifier for the visit. ### Google Ads: the offline conversions Download the CSV from the Tracking page and import it under Conversions > Uploads. It holds the click id, the conversion name, the time (UTC), the value and currency and an order id, for people who consented to advertising and arrived on a Google click. The download is recorded in the audit trail. ## Outcomes and error codes Source: https://app.doconure.com/docs/errors Every failure and every warning, with its stable code and its four-digit number. Branch on the code, never on the message. ## Esiti e codici di errore Ogni fallimento risponde con la stessa struttura, così un solo parser gestisce tutti i casi: ``` { "code": "TEMPLATE_VERSION_NOT_ACTIVE", "number": 1204, "message": "version 2 of template 'x' is not active; active versions are [1, 3]", "details": { "requestedVersion": 2, "activeVersions": [1, 3] }, "retryable": false } ``` Ogni esito ha anche un numero. 1204 e TEMPLATE_VERSION_NOT_ACTIVE sono la stessa cosa detta due volte: nel software si ramifica sul codice, il numero si cita quando ci scrivi dalla pagina dei contatti (/contatti) o ovunque una stringa lunga sia scomoda da conservare. Entrambi sono permanenti — un numero non viene mai riusato, rinumerato o spostato su un altro esito. Iniziano da 1000 così uno non può mai essere confuso con uno stato HTTP, e le prime due cifre indicano la famiglia: accesso, la richiesta, modello e versione, il file del modello, i dati rispetto al modello, documenti, firme, flussi, limiti, un nostro problema e non tuo. I warning iniziano con 2, così la prima cifra da sola dice se la chiamata è riuscita. Ramificati sul codice, mai sul messaggio. Il codice è un contratto stabile; il messaggio è un suggerimento per una persona e può cambiare formulazione in qualsiasi momento. details è presente quando c'è qualcosa su cui agire, e retryable: true appare solo quando ripetere la stessa richiesta potrebbe davvero avere successo. In the console, API errors (under Build) counts what your keys were refused over the last day, week or month, by code, key and operation; each code links to its row below. ### The full table | Code | HTTP | When | Retry? | | Success | | (no code) | 200 | PDF generated; body is the file. Version used is in X-Template-Version. | — | | Authentication | | MISSING_CREDENTIALS1001 | 401 | No Authorization: Bearer header. | no | | INVALID_API_KEY1002 | 401 | Key unknown, revoked or expired. Same answer for all three, so the API cannot confirm a key once existed. | no | | TENANT_INACTIVE1004 | 403 | The key is valid but its organisation cannot act: it is suspended, its registration is not approved yet (details.registration), it was archived after a long period without use (details.lifecycle is archived; ask to have it reactivated, nothing was deleted) or it was anonymised (details.lifecycle is anonymised). | no | | SUITE_NOT_ENABLED1005 | 403 | The key is valid and the organisation is active; the product you called is not switched on for it. The three are sold separately: composition (/api/v1/compose/documents/*, /api/v1/compose/templates/*), signature (/api/v1/sign/*) and the one-call flow (/api/v1/flow/*). details.suite names the one you called and details.enabled lists the ones you may. Nothing is charged. Your organisation’s administrator can see the same list under Settings, and only we can change it. | no | | FEATURE_NOT_ENABLED1007 | 403 | You asked for something your organisation has not bought: a trust service (settings.qeSeal or settings.qualifiedTimestamp set to true), an SMS one-time code (validationMode: "sms"), a bulk operation (/bulk/compose, /bulk/sign, /bulk/flow) or a signature level. Refused on every API (composition, signature and the flow alike) and never produced without it: unlike your organisation’s own settings, which hand back the ordinary document and say so in a warning, this one is invisible from your side, so a silent downgrade would be undetectable. details.feature names which one (qeSeal, qualifiedTimestamp, smsValidation, bulkCompose, bulkSign, bulkFlow, signatureSes, signatureAes, signatureQes) and details.enabled lists what you do hold; GET /api/v1/usage shows the same list. Nothing is charged. Ask your DoConure contact to activate it; only we can. | no | | IP_NOT_ALLOWED1003 | 403 | The key is valid; the address it arrived from is not on your organisation's allowlist. Distinct from INVALID_API_KEY on purpose — moving to a new server needs to be told apart from a bad key, and details.address echoes the address back, because it is not a secret from its own owner. | no | | ADMIN_LOGIN_REQUIRED1006 | 401 | A console page reached without a session. Not returned to API callers. | no | | TOKEN_SCOPE_NOT_ALLOWED1008 | 403 | Your organisation may call this; the token you sent was not granted the scope it needs. Distinct from SUITE_NOT_ENABLED, which is your organisation's own purchase — this is the token's own restriction, from the scopes you chose when you created it. details.missingScopes names what is missing. Create a token with the scope you need; an existing token's scopes only ever shrink, never grow, so re-issuing rather than editing is the fix. | no | | FEATURE_NOT_AVAILABLE_IN_TEST_MODE1009 | 422 | You sent isTest: true together with a feature that is paid for at an external provider: qeSeal, qualifiedTimestamp, an SMS one-time code (validationMode: "sms") or an AES/QES level. Refused before anything is reserved, sent or charged — a test call never spends money and is never silently downgraded. details.features names each. Send the call without isTest, or without the feature. | no | | FEATURE_NOT_AVAILABLE_IN_BULK1010 | 422 | The batch asked for something a bulk job cannot do yet: a seal or a qualified timestamp (charged after the fact, so a batch of them could overdraw what was reserved), a document password, a responseFormat (an item has no response of its own) or a filename (one name for many files). details.fields names each. Nothing was reserved; send the same batch without them, or send those documents one at a time. | no | | REQUIRED_FEATURE_NOT_AVAILABLE_IN_TEST_MODE1011 | 422 | You sent isTest: true to an organisation whose own settings require a seal or a qualified timestamp on every document (seal_mode / timestamp_mode = always). A test call never buys one, and a document the policy says must be sealed is never returned unsealed, so the call is refused before anything is reserved, sent or charged. details.features names each. Send the call without isTest, or change the setting. Where the setting is only optional, isTest skips the seal with PAID_EXTRA_SKIPPED_ON_TEST instead. | no | | TIMESTAMP_REQUIRES_SEAL1012 | 422 | You asked for a qualifiedTimestamp without a qeSeal (or your organisation's timestamp_mode is always and the document is not sealed). The qualified timestamp is an option of the qualified seal and is never applied on its own, so the call is refused before anything is rendered, reserved, sent or charged. Send qeSeal: true as well, or drop qualifiedTimestamp; a sealed document without a timestamp is fine. details.source says whether the request or the organisation's setting asked for it. | no | | Template and version | | TEMPLATE_NOT_FOUND1201 | 404 | No such id, or it belongs to another tenant — deliberately indistinguishable. | no | | TEMPLATE_DEACTIVATED1202 | 409 | The template is switched off entirely. Activate a version in the admin. | no | | TEMPLATE_VERSION_NOT_FOUND1203 | 404 | You pinned a number that was never created. details.activeVersions lists what exists. | no | | TEMPLATE_VERSION_NOT_ACTIVE1204 | 409 | You pinned a version that exists but is off. details.activeVersions tells you what to use instead. | no | | NO_ACTIVE_VERSION1205 | 409 | The template exists and is on, but no version is active. | no | | Signatures — Signer Fly | | SIGNATURE_LEVEL_UNAVAILABLE1601 | 501 | You asked for AES or QES. They are coming soon, in the next wave; only SES is produced today, and the request is refused, never downgraded — under eIDAS those are different legal objects, so being handed a weaker one silently would be worse than being told no. details.available lists what can be issued. See Signature levels: SES, AES, QES. | no | | SMS_VALIDATION_UNAVAILABLE1624 | 422 | A signer was set to validate by sms and the code cannot be delivered: the platform has no SMS provider configured (details.reason: provider_not_configured) or the number is outside the countries the sender is registered for (country_not_registered); details.signer names the signer. Refused when the envelope is created, before anything is sent, rather than an envelope nobody can sign or a code that quietly goes by email. Use validationMode: "email" for that signer. | no | | ENVELOPE_NOT_FOUND1602 | 404 | No such envelope, or it belongs to another tenant — deliberately indistinguishable, as with templates. | no | | ENVELOPE_NOT_EDITABLE1603 | 409 | It has already been sent. An envelope is composed once and sent once. | no | | ENVELOPE_CLOSED1604 | 409 | Completed, cancelled, declined or expired. details.status says which. | no | | ENVELOPE_NOT_CLOSED1605 | 409 | The evidence bundle was requested before the envelope reached a terminal status. details.status says what it is now. | no | | DOCUMENT_NOT_SIGNABLE1606 | 422 | The document exists but is not a PDF. | no | | DOCUMENT_SOURCE_UNAVAILABLE1607 | 501 | Retired 2026-10-04 and kept so the number is never reused. An uploaded PDF (fileId, or the inline fileBase64) is now a supported envelope source, so nothing raises this any more. If you meet it in old logs, it meant an upload was refused before uploads existed. | no | | STORED_FILE_IN_USE1620 | 409 | You tried to delete a stored file (a PDF you uploaded to be signed, or any file by fileId) while an open envelope — a draft, a sent or an in-progress one — still uses it. details.envelopeIds names them. Cancel or finish those first; a file that only finished envelopes use can be deleted, and its bytes are kept for their evidence until their retention ends. | no | | SOURCE_FILE_CHANGED1621 | 409 | You pinned a sha256 beside a fileId and it is not that file's. A stored file never changes — a new version is a new upload with a new fileId — so this means the id names a different file from the one you meant. | no | | SIGNER_FIELD_INVALID1622 | 422 | Signer-facing. A field the sender required (a text, a ticked checkbox, a dropdown choice) was left empty, or answered with something that is not allowed. The signature was not taken: the signer corrects the answer and signs again. details.fields names each. | no | | CALLBACK_DELIVERY_NOT_FOUND1623 | 404 | The webhook delivery you asked the console to send again does not exist, or belongs to another organisation — deliberately indistinguishable. | no | | CALLBACK_ENVIRONMENT_UNKNOWN1625 | 422 | callbackUrl.env names no callback environment of your organisation, or you sent a path without env and no environment is the default. Refused at the call, before anything is created or charged. details.known lists your environments. | no | | CALLBACK_PATH_INVALID1626 | 422 | A callback path that could change the host (a scheme, //, @, .., a backslash or a fragment), a full URL sent together with env, or a path mixed with a full URL. Send only the end of the URL; the environment holds the host. details.reason says which. | no | | CALLBACK_ENVIRONMENT_IN_USE1627 | 409 | The console was asked to delete a callback environment that open envelopes, running batches or queued callbacks still name. Wait for them to close; details counts them. | no | | CALLBACK_ENVIRONMENT_INVALID1628 | 422 | Console only, never an API answer. A callback environment could not be saved: the sigla, the base URL, the type of authentication or its credentials. details.field names which. | no | | SIGNATURE_TAG_UNKNOWN_SIGNER1629 | 422 | A signature tag in the document, such as {{ signature.C }}, names a signer your request does not have. Add the signer, or remove the tag from the template. A flow is refused before anything is composed or charged when the tag is in the template; a tag typed in an uploaded annex is read after composing. details.tags and details.signers say which. | no | | SIGNER_NOT_PLACED1630 | 422 | The document places its signers with signature tags, and a signer has neither a tag nor fields, or its tag was not in the finished document (for example inside a condition that was false). Add a tag or fields for them. details.signers names them. | no | | SIGNATURE_TYPE_NOT_ALLOWED1611 | 422 | The signer chose a way of making a mark that the organisation does not permit. Never substituted for an allowed one — somebody who drew their name and found a typed one on the document would have signed something they did not do. The signing page offers only the ways the organisation accepts, so a signer meets this only by sending another way anyway, and is then shown the form again with the reason. | no | | DELEGATION_NOT_ALLOWED1613 | 403 | Signer-facing. They tried to pass the request to somebody else and may not: the organisation forbids it for this envelope, or it has already changed hands as often as it may. | no | | SIGNING_LINK_INVALID1608 | 404 | Signer-facing. The link is unknown, already used, or was killed by a cancellation. | no | | SIGNING_LINK_EXPIRED1609 | 410 | Signer-facing. Past the envelope's deadline. Issue a new envelope. | no | | SIGNING_OUT_OF_TURN1610 | 409 | Signer-facing. A sequential envelope, and it is somebody else's turn. | no | | RECIPIENT_ALREADY_SIGNED1612 | 409 | Signer-facing. That person has already signed; links are single-use. | no | | OTP_REQUIRED1614 | 403 | Signer-facing. No live code — ask for one first. | no | | OTP_INVALID1615 | 403 | Signer-facing. Wrong code. The attempt is counted. | no | | OTP_TOO_MANY_ATTEMPTS1616 | 429 | Signer-facing. Too many wrong guesses on one code, or too many codes requested. A fresh code resets the guesses. | no | | RECIPIENT_NOT_FOUND1617 | 404 | No such recipient on that envelope — including one that belongs to another tenant, which answers the same as absent. | no | | VALIDATION_MODE_UNAVAILABLE1618 | 501 | Reserved for a validationMode this service does not yet support. Not raised for "sms" any more — that mode works, with a real SMS gateway (refused with SMS_VALIDATION_UNAVAILABLE where a code cannot be delivered) — but kept for whatever validation mode comes after it. | no | | RECIPIENT_NOT_REQUIRED1619 | 409 | Signer-facing. This recipient's group signs "OR" and somebody else in it already signed — their own signature was never required, and there is nothing left to act on for that link. | no | | Flows — Document Fly | | FLOW_NOT_FOUND1701 | 404 | No such flow, or it belongs to another tenant — deliberately indistinguishable. | no | | FLOW_CLOSED1702 | 409 | The flow has already finished, been cancelled, or expired. | no | | Request payload | | INVALID_REQUEST_BODY1101 | 422 | Body does not match the expected shape. details.fields names each offending field. | no | | IDEMPOTENCY_KEY_REUSED1104 | 422 | The same Idempotency-Key arrived with a different request. Answering it with the first call’s result would look to you exactly like success, so it is refused instead. Use a new key, or send the same request. | no | | IDEMPOTENCY_IN_PROGRESS1105 | 409 | A request with this key is still running. Retryable, with a Retry-After: you are doing the right thing, just early. | yes | | IDEMPOTENT_RESULT_GONE1106 | 410 | The key is known and its result cannot be served again — the generation was dataless and kept nothing, or the document reached its retention date. details.jobId names the work: you were charged once, not twice, which is what you were retrying to find out. | no | | BULK_INPUT_INVALID1107 | 422 | The batch (or the uploaded file) is malformed as a whole: a line that is not JSON, an item without data, a duplicate externalId, an empty batch, both or neither of items and fileId. details.problems lists up to twenty, each with its line (a file) or index (inline). Nothing was reserved or created; fix the input and send it again. | no | | IDEMPOTENCY_KEY_REQUIRED1109 | 422 | You asked for a qeSeal on a compose or flow call that carries no Idempotency-Key. A retry after a timeout could not be told from a new request and would buy a second seal at the provider, which cannot be reversed. Send the header (any unique string, reused only when you retry the same call). Nothing was reserved, sent or charged. | no | | FILE_REJECTED1121 | 422 | An uploaded file was refused: empty, not a PNG, JPEG, WebP or PDF by its bytes (SVG is refused), unreadable, encrypted, a PDF with scripts, launch actions, embedded files or forms that submit, or a PDF with more than 500 pages (200 for a PDF uploaded to be signed). details.reason says which; nothing was stored. | no | | INVALID_JSON1102 | 400 | The body is not parseable JSON. | no | | JSON_STRUCTURE_LIMIT_EXCEEDED1108 | 422 | The JSON body is nested deeper, or holds more values, than the platform reads, and was refused before it was parsed. details.limit is depth or nodes, with the figure in force. A long list belongs in a bulk job or a stored file. | no | | MISSING_TEMPLATE_FIELD1401 | 422 | An absent field renders empty by default — see Missing and extra fields — so this is raised only when the template is set to reject incomplete data, or when a field is absent from an object the template does reach. details.fields / details.field names them. | no | | TEMPLATE_DATA_MISMATCH1402 | 422 | The template and the payload do not fit together: a field that is present and of the wrong kind. {{ importo + 1 }} against "quarantadue" cannot render, and leniency does not cover it — leniency is about fields that are absent. details.problem names the kind of failure. Never retryable: the identical request fails identically. (Before 2026-09-19 these answered INTERNAL_ERROR, which is marked retryable — so a caller obeying that flag retried forever.) | no | | PAYLOAD_SCHEMA_VIOLATION1403 | 422 | The payload does not satisfy the template's JSON Schema and the template is set to payloadValidation: "reject". Refused before any credit is reserved or any slot taken, so it costs nothing. details.violations lists the first ones as {path, keyword, message} (JSON pointer into your data), details.count how many there were. The offending value is never in it. Never retryable: fix the data, or set the template to warn. | no | | DATALESS_NOT_AVAILABLE1103 | 422 | You sent settings.dataless: true to /api/v1/flow. A document that is being signed has to be kept — a signature with nothing to show is not a signature, and its evidence is kept for ten years — so the request is refused rather than half-honoured. Compose it at /api/v1/compose/documents/generate if nothing may be stored. Refused before anything is generated or charged. | no | | PAYLOAD_TOO_LARGE1803 | 413 | The request body is over 25 MB, the largest this service accepts, so nothing was received or processed. On /api/* and /verify.json the answer is the usual JSON error body (output.error included), like every other refusal; smaller limits of your own (a template, an uploaded file) are refused with this code too and say the limit in the message. | no | | Template file | | TEMPLATE_FILE_INVALID1301 | 422 | The stored template file could not be read or merged. | no | | TEMPLATE_FILE_REJECTED1302 | 422 | Refused at upload: macros present, or a suspicious archive. | no | | JSON_SCHEMA_INVALID1303 | 422 | The jsonSchema sent with a template or a version cannot be enforced: not valid JSON Schema, a keyword outside the supported subset, a $ref that is not local to the schema, or past the size, depth or pattern caps. Refused at upload so that no compose call ever meets it. details.problems names each, with its path inside the schema. | no | | Documents | | DOCUMENT_NOT_FOUND1501 | 404 | No such document id, or it belongs to another tenant. | no | | BULK_JOB_NOT_FOUND1502 | 404 | No bulk job with that id, or it belongs to another organisation — the two are indistinguishable on purpose. | no | | BULK_FILE_NOT_FOUND1503 | 404 | No uploaded bulk file with that id for your organisation, or a job already used it, or it expired (an upload not used within its lifetime is deleted). Upload it again. | no | | BULK_RESULTS_UNAVAILABLE1504 | 409 | The result bundle of a bulk job cannot be built: the job has not finished, or its per-item results were deleted by retention. details.reason says which (job_not_finished, results_expired). The documents themselves follow their own retention. | no | | BULK_RETRY_NOT_POSSIBLE1505 | 409 | The failed items of a bulk job cannot be put back: it has not finished, nothing in it is retryable, the payloads were not kept (an organisation that composes without keeping data), or its results expired. details.reason says which; details.skipped lists items refused one by one. An item whose outcome is unknown is never repeated. | no | | STORED_FILE_NOT_FOUND1520 | 404 | No stored file with that id for your organisation: it never existed, was deleted, expired, or belongs to another organisation — the same answer for all. Upload it again. | no | | DOWNLOAD_LINK_NOT_FOUND1521 | 404 | The document or download link you named to mint, list or revoke is not yours, or does not exist. | no | | DOWNLOAD_LINK_INVALID1522 | 404 | The public download address is unknown, altered, revoked, already used (single use) or no longer matches its document — one answer for all, so it cannot be probed. Ask for a new link. | no | | DOWNLOAD_LINK_EXPIRED1523 | 410 | The download address was genuine and has expired. Mint a new one. | no | | Service side | | RENDERING_FAILED1901 | 500 | The merge step failed for a reason that is not the payload's fault. | no | | CONVERSION_FAILED1902 | 500 | PDF conversion failed, or a settings-driven post-processing step did (PDF/A export, colour conversion). | no | | CONVERSION_TIMEOUT1903 | 504 | Conversion or post-processing exceeded its time limit. | yes | | STORAGE_UNAVAILABLE1904 | 503 | Template or document storage could not be reached. | yes | | INTERNAL_ERROR1905 | 500 | Anything unclassified. | yes | | TRUST_PROVIDER_NOT_CONFIGURED1906 | 503 | This deployment cannot perform what you asked for — the platform does not offer the qualified seal and timestamp (details.comingSoon: true, whatever your organisation holds; they are on sale now, so this is a switch we turned off), or no sealing certificate or timestamp authority is configured, or a provider does not offer the one you asked for. Ours to fix, not yours, and distinct from FEATURE_NOT_ENABLED for that reason. The document is refused, before anything is reserved or charged, rather than returned without what it was asked to carry. | no | | TRUST_PROVIDER_UNAVAILABLE1907 | 503 | The seal or timestamp provider was asked and could not be reached, or refused us — a connection that never got through, a rate limit, an upstream failure. details.category carries the normalised reason. Retryable: the identical request works once they answer, and nothing about it has to change. | yes | | SEAL_FAILED1908 | 502 | A qualified seal was asked for, everything allowed it, and it could not be applied — so no document is returned. Deliberate: a composition can be run again, and handing back an unsealed PDF where a seal was asked for would be claiming a property the file does not have. Nothing was charged for the seal. Not marked retryable, because the provider may have performed the operation before the failure and a blind retry could buy a second one. details.category: "OUTCOME_UNKNOWN" is that case exactly: the provider received the request and its answer was lost. An envelope behaves differently on purpose — see SEAL_NOT_APPLIED. | no | | TIMESTAMP_FAILED1909 | 502 | The same, for the qualified timestamp. | no | | BULK_JOB_TIMED_OUT1910 | 504 | Seen only as the error of a bulk item: the item was still waiting when its job passed its deadline, so it was not run. Its credit was released. Put it in a new batch. | yes | | PROVIDER_BILLING_PROFILE_INCOMPLETE1911 | 503 | The seal provider refused because our own account with it is not complete. This is ours to fix and nothing in your request can change it: you are told the service is temporarily unavailable, nothing is charged for the seal (anything already paid for it at the send is returned), and the identical request works once we have fixed it. | yes | | Limits | | QUOTA_EXCEEDED1801 | 429 | The prepaid balance does not cover the operation: no credit left. details carries requestedCredits, currentBalance, reservedCredits, availableCredits and missingCredits, and a flow names its breakdown. The whole request is refused before any work, and nothing was reserved. | no | | RATE_LIMIT_EXCEEDED1802 | 429 | More than max_requests_per_minute calls in the last 60 seconds. Carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. | yes | | SERVER_BUSY1804 | 503 | Every conversion slot on this instance is in use (measured ceiling, not a per-tenant limit). Carries Retry-After; a well-behaved caller waits that long before retrying rather than immediately. | yes | | SERVICE_PAUSED1805 | 503 | An operator has paused generation across the whole platform — the emergency switch that exists so an incident can be stopped without a deploy. Nothing is wrong with your request and nothing about it needs to change; the identical call succeeds once the pause is lifted. Carries Retry-After. | yes | | RECIPIENT_VOLUME_EXCEEDED1806 | 429 | Sending this envelope would take your organisation past the number of distinct addresses it may write to in a rolling 24 hours — signers, cc and bcc together, across every envelope. Outbound mail leaves under a shared sender, so this protects the deliverability of everybody’s signature requests rather than any one account. Nothing was charged and the envelope is untouched: it is still a draft and the same call works tomorrow. The ceiling is higher once your organisation has completed a purchase, and an operator can raise it further for a customer that genuinely sends at volume. | yes | | TEST_QUOTA_EXCEEDED1807 | 429 | Your organisation has spent its shared daily pool of isTest items (20 a day unless an operator set another number); a whole call is refused, never trimmed to what is left. details carries requestedItems, remainingTestItems and dailyLimit. Nothing was charged — a test call never touches credit. The pool resets at UTC midnight. A test item is spent on the attempt, so a retried failing call still uses one. | no | | TEST_CAPACITY_TEMPORARILY_LIMITED1808 | 503 | A test (isTest) call was turned away to protect production: a production request was waiting, the test share of the render slots was in use, or the test queue was full. Nothing was reserved, charged or taken from the test pool. Carries Retry-After; sandbox traffic is throttled first by design, so back off and try again. | yes | | QUEUE_CAPACITY_EXCEEDED1809 | 503 | The line for a render slot in this request's class is full, so waiting would only have ended in SERVER_BUSY. Refused before any work; nothing was charged. Carries Retry-After. | yes | | TENANT_CONCURRENCY_LIMIT_REACHED1810 | 429 | Your organisation already has as many documents rendering (or waiting to) as one organisation may hold at once. Other organisations are not affected. Nothing was charged. Carries Retry-After; sending fewer calls at the same moment avoids it. | yes | | BULK_ITEM_LIMIT_EXCEEDED1811 | 422 | The batch has more items than allowed — the lowest of your own options.maxItems, your organisation’s limit and the platform’s, or, for an isTest batch, the test cap. Refused whole and never trimmed. details.requestedItems and details.allowedItems say by how much. Nothing was reserved. | no | | JOB_TOO_LARGE1812 | 413 | The bulk body or the uploaded file is over its byte limit (details.limit). For a large batch upload a file instead of sending the items inline. | no | | BULK_CONCURRENCY_LIMIT_REACHED1813 | 429 | Your organisation already has as many bulk jobs running, items queued, unused uploads or submissions in the last minute as the platform allows; details.what says which. Nothing was reserved. Retry when a job finishes. | yes | | MAINTENANCE_MODE1814 | 503 | The platform is in planned maintenance: every call is answered with this until it ends, and nothing is done or charged. details.state is on. While maintenance is about to start it is pending: the bulk jobs already running are finished, and a new bulk job (or a retry of one) is refused with this code; single calls still work, and every answer carries X-Maintenance-Pending with the seconds left. Carries Retry-After; the identical call works afterwards. | yes | | PURCHASE_AMOUNT_TOO_LARGE1815 | 422 | One credit purchase is more than EUR 1,000.00, VAT excluded — the most a single purchase may be (EUR 1,220.00 with Italian VAT). Refused before any order is opened or any payment provider is called: nothing is reserved or charged. Make several purchases, or contact us (the contact form, or Report a problem in the console) for a larger amount. details.netCents and details.maximumNetCents say by how much. | no | | SUBSCRIPTION_ALLOWANCE_EXHAUSTED1816 | 429 | A counter of your plan (signatures, documents or calls) has nothing left for this request, and paying with credit after it is off (the default). Refused whole before anything is reserved or charged: a batch is never half-admitted, and a flow is not half-paid. Turn on Pay with credit when a counter of the plan is used up on the Billing page, renew, or wait for the next period. details.remaining and details.requested (by counter), details.missing (what is short) and details.endsAt say where you stand. An SMS code to a signer is never refused this way. | no | | FILE_LIMIT_REACHED1830 | 429 | Your organisation already keeps as many stored files as the platform allows. Delete some, or wait for them to expire. | no | ### Warnings: a call that succeeded, having done less Some settings are not yours alone to decide. An organisation can require its signed documents to be sealed, or forbid it, from its own Settings page — and that setting is a ceiling, not a default: your request may go under it, never over. When it overrules something you asked for explicitly, the call still succeeds and the answer carries a warnings array. ``` { "id": "…", "seal": false, "timestamp": false, "padesProfile": null, "warnings": [ { "code": "SEAL_NOT_ENABLED", "number": 2001, "message": "this organisation does not seal signed documents, so 'settings.seal' was ignored; the document was produced unsealed", "details": { "setting": "seal_mode" } } ] } ``` A warning is not a softer error. It arrives on a 2xx and the operation went ahead. The rule for which you get: a request that would hand you something different from what you asked for is refused (that is why level: "AES" is a 501); a request that hands you less — the ordinary document, without an attestation — succeeds and warns. Read seal, timestamp and, once the envelope completes, padesProfile from the response rather than assuming what you sent was used. They are echoed on every read of the envelope, and the warnings are stored with it, so a later GET /api/v1/sign/envelopes/{id} still tells you. | Code | When | | SEAL_NOT_ENABLED2001 | You asked for a seal; the organisation has sealing switched off. The document is produced unsealed. | | TIMESTAMP_NOT_ENABLED2002 | The same for a timestamp. | | SEAL_REQUIRED_BY_ORGANISATION2003 | You asked not to seal; the organisation seals everything. It is sealed, and charged for. | | TIMESTAMP_REQUIRED_BY_ORGANISATION2004 | The same for a timestamp. | | SEAL_NOT_CONFIGURED2005 | Wanted by everybody, and this platform has no sealing certificate installed. Ours, not yours — nothing in your request can fix it. | | TIMESTAMP_NOT_CONFIGURED2006 | The same, for the timestamp authority. | | SEAL_NOT_APPLIED2007 | Configured, wanted, attempted at completion, and it did not work. The envelope still completed: a third party being unreachable never costs a signature that has been given. | | TIMESTAMP_NOT_APPLIED2008 | The same. Appears on the envelope after it completes, not on the create call. | | SEAL_NOT_ENTITLED2009 | Your organisation’s own setting seals every document, and the qualified seal is not active on the account. The document is produced without it and nothing is charged. You see this only when you asked for nothing: a request that asks explicitly is refused with FEATURE_NOT_ENABLED instead, because then somebody would be believing something untrue. | | TIMESTAMP_NOT_ENTITLED2010 | The same, for the qualified timestamp. | | DELEGATION_NOT_ENABLED2102 | You asked to let signers pass the request on; the organisation does not allow it. The envelope is created and the option is off. | | DELEGATION_REQUIRED_BY_ORGANISATION2103 | You asked not to allow it; the organisation always does. | | TEMPLATE_FIELDS_MISSING2104 | The template reads fields your payload did not carry. They rendered empty and the document was produced, because this template is not set to refuse incomplete data (errorOnMissingFields). details.fields names every one of them — this is the warning to read when a PDF comes back with blanks in it. | | RENDER_NOTES2105 | The renderer had something to say while producing this document: a value it shortened, a section that did not appear, a comparison it could not make. details.notes carries the texts and details.count how many — except on a dataless generation, which gets the count alone, because a note quotes your own values. | | PAYLOAD_SCHEMA_VIOLATIONS2106 | The payload does not satisfy the template's JSON Schema and the template is set to warn (the default), so the document was produced anyway. details.violations lists the first ones as {path, keyword, message} and details.count how many there were. A value from your payload is never in it, so it is the same on a dataless generation. | | TRUST_NOT_QUALIFIED2107 | The seal or timestamp on this document was applied by a provider that is simulated or not declared qualified (details.feature, details.simulated). The structure is genuine and verifies, but it carries no eIDAS presumption of origin or time. The call did what it said; treat the document accordingly. The same facts are in output.compose.trust as qualified and simulated. | | COMPRESSION_SKIPPED2120 | compressionLevel was asked for and not applied: the document is converted to PDF/A, is black-and-white, or compressing would not have made it smaller (details.reason: pdfa, blackwhite, not_smaller). The document is exactly what the request without the setting would produce. | | PDFA_NOT_VERIFIED2121 | A PDF/A conversion was made and the result was not validated against a PDF/A checker, so no conformance is claimed. output.compose.pdfa.verified is false. Validate the file yourself if you rely on it. | | ATTESTATION_REFUNDED_ENVELOPE_CLOSED2130 | An envelope was cancelled, expired or declined after it was sent, so the seal and the timestamp it had paid for were returned to the credit they came from. The signature is not returned: the work of sending was done. details.units and details.credits say what came back. | | ATTESTATION_REFUNDED_NOT_APPLIED2131 | The seal or timestamp paid at the send could not be applied when the last signer finished (the provider or the authority was down or refused), so it was returned at once. The envelope completed without it, as SEAL_NOT_APPLIED says. | | ATTESTATION_REFUNDED_OUTCOME_UNKNOWN2132 | The seal's request reached the provider and the answer was lost. We cannot say whether it was performed, so it was returned at once and the request is not repeated: the risk is ours, not yours. The envelope completed without the seal. | | DOCUMENT_NOT_STORED2201 | This generation was dataless: the PDF in your hands is the only copy there will ever be, and nothing about the request was written down. Arrives as a header (X-Warning-Code) rather than in a body, because the body of that call is the PDF. X-Document-Id is empty for the same reason. See Generating a document. | | DOCUMENT_STORED_FOR_SIGNATURE2202 | Your organisation composes dataless and this call was a flow, so the document was kept — it is being signed. What you sent was still not recorded. The half that could be honoured, was, and this says which half. | | DOCUMENT_STORED_FOR_BULK2203 | Your organisation composes dataless and this was a bulk batch, so its documents were kept — a batch answers with no bytes, and they are fetched afterwards. What you sent was not recorded, and each item’s payload is wiped when the item finishes. | | PAID_EXTRA_SKIPPED_ON_TEST2101 | This is a test organisation, or an isTest call whose organisation setting switches on something that costs real money at a third party (a seal or a timestamp). A call that itself asks for the feature together with isTest is refused with FEATURE_NOT_AVAILABLE_IN_TEST_MODE (1009) instead. Refused before it is bought, whatever the organisation's own settings say — test documents are not worth paying for. At every door — composition, envelope and flow. details.feature names which: qeSeal or qualifiedTimestamp. Use the live organisation to see a sealed document. | Rate limiting is per organisation, and a key can have its own limit too. Several active keys on one account share the organisation's per-minute budget; a key given a lower limit of its own is refused on that one first (details.limitedBy: "token", see A limit for one key). Besides the per-minute limits there are size limits (a request body of at most 25 MB, and the sizes under Reference → Limits in your console), the daily pool of test calls and a 24-hour ceiling on signing invitations. SERVER_BUSY is different: it is a shared ceiling across every tenant on the instance, not a per-tenant number — it exists to protect the machine, not to meter your account. ## Missing and extra fields Source: https://app.doconure.com/docs/leniency What happens when the payload does not match the template: which fields render empty, which are refused, and the warning that names them. ## Missing and extra fields Two rules that make integration forgiving, and one consequence you should know about. A field the template wants but your payload omits renders empty. It is not an error. A field your payload carries but the template never reads is simply ignored. An under-sized or over-sized payload still produces a document. So {{ vendor.price }} with no price in the data leaves a blank where the number would be, and the request still answers 200. The consequence: a misspelled field is not an error. Send vendro instead of vendor and you get a document with a gap, not a refusal — but not in silence: the answer carries the warning TEMPLATE_FIELDS_MISSING (2104), naming what the template wanted and did not get. The payload of each request is also kept as received (for 12 months, and never for a dataless call). Open Documents in the console, click Details on the generation, and the page lists wanted by the template but absent and sent but never read side by side — a typo shows up in the second list immediately. ### Case does not matter Tag and payload casing need not agree. All of these are the same field: ``` template: {{ VendoR.Price }} {{ vendor.price }} {{ VENDOR.PRICE }} payload: {"Vendor": {"PRICE": …}} {"vendor": {"price": …}} ``` Both sides are folded to lowercase before merging, loop names included: {% for R in Forniture %} reads the array forniture and {{ r.SITE }} reads site on each item. Values are never touched — a company name keeps its capitals. The one thing to avoid is a payload that carries the same field in two casings (Vendor and vendor together): that is ambiguous, and the populated one wins rather than whichever came first. ## Writing a template Source: https://app.doconure.com/docs/template-writing The placeholder syntax, in Word, Excel and HTML. Everything a document designer needs and nothing a programmer does. ## Writing a template A template is your normal document with placeholders where the data goes. You write it in the program you already use - Word for a DOCX, any editor for an HTML file, Excel for an XLSX - and upload it; every generation fills it in with the JSON of that call. One rule to remember: Placeholders mirror the structure of the JSON. If the JSON has customer.name, the document says {{ customer.name }}. That is all. ### Fields and objects ``` { "number": "2026-000147", "customer": { "name": "Acme Srl", "city": "Milan" } } ``` ``` Invoice no. {{ number }} Customer: {{ customer.name }} City: {{ customer.city }} ``` A field at the root of the JSON is written with its bare name; a field inside an object is reached with a dot, nested as deep as you need ({{ customer.address.city }}). The same placeholder may appear more than once in a document. ### Writing one, start to finish - Start from the document you already send - an invoice, a contract, a letter - laid out exactly as it should look. - Replace each variable part with a placeholder named after its field in the JSON. In Word, type each placeholder in one go: editing it in the middle splits it invisibly (Common mistakes). - Repeat a row per item of a list with a loop (Lists and repeating rows), and show a part only when it applies with a condition (Conditional parts). - Tick checkboxes, place pictures and charts from the data: checkboxes below, pictures and charts in Making a value fit. - Mark where each signer signs if the document will be signed: signature tags below. - Upload it, then Validate and Preview in the console: Validate compares a sample JSON with what the file asks for, and Preview shows the finished PDF, watermarked. Neither costs credit. ### In Word, in HTML, in Excel | Format | Where the placeholders go | | Word (DOCX) | In the text and in table cells; loops and conditions as in the pages linked above. Fields and checkboxes are filled in the header and footer too (a reference number, a date); keep repeating rows and conditional blocks in the body. | | HTML | In the markup, anywhere text goes; loops and conditions are ordinary {% for %} and {% if %} blocks around the elements they repeat or hide. | | Excel (XLSX) | In cells; loops and conditions work on whole rows. | Which format suits which document, and what each can do: What each format can do. ### Bold, fonts and layout come from the template A value arrives as plain text and takes the look of the place it lands in. All formatting is written in the template itself: - Word: format the placeholder. Make {{ customer.name }} bold, or give it a font, a size or a colour, and the value comes out that way. Format the whole placeholder, braces included, the same way. - HTML: your markup and CSS, for example {{ customer.name }}. A value is always printed as text: if the data contains , the document shows those characters, it does not turn bold. - Excel: the cell's own format (font, fill, number format). There is nothing to write in the JSON to make a value bold, and no formatting code for it. **words** in a value is printed as it is, asterisks included: the double asterisks are understood only in the message of a signature request mail (Writing the message), never in a document. Numbers and dates are printed as received: send them already formatted (Making a value fit the space you left for it). ### Where a signer signs A document that will be signed says where, with a tag written the same way: {{ signature.A }} places the signature of the signer whose key is A in the request's signers; {{ signature.A.name }} and {{ signature.A.date }} place their name and the date. The tag prints nothing and is removed from the finished PDF. Sizes, the rules and the alternative with coordinates: Where the signature goes on the page. ### Ticking a checkbox (Word) A checkbox in a Word template is set by a true or false of the same name in the data: ``` "data": { "consenso": true, "newsletter": false } ``` - Name the checkbox: a legacy form checkbox (Developer → Legacy tools) by its bookmark, in its properties; a check box content control (Developer → Check box) by its Tag, or else its Title. Upper and lower case do not matter. - true ticks it, false clears it. - No key, or null: the box stays exactly as it is in the template. - Anything else ("yes", 1) is not guessed at: the box stays as it is, and the answer's warnings say a checkbox value was not true or false. The template's page in the console lists the checkboxes it found beside its fields, so you can see which names the data may set; a signature tag ({{ signature.A }}) is not listed as data to send, because it is where a signer signs, not a value. Only names at the root of the data are read. In an HTML template you write the box yourself, for example {{ '☒' if consenso else '☐' }}. Managing templates from a script: Managing templates from a script. Repeating rows: Lists and repeating rows. If/else: Conditional parts. Sizing a value to the space you left for it: Making a value fit the space you left for it. Symptom-first troubleshooting: Common mistakes. The PDF is made with free fonts: which fonts keep your Word layout. ## Lists and repeating rows Source: https://app.doconure.com/docs/template-lists Repeating a table row per item, including the one attribute that keeps the column headers on page two. ## Lists and repeating rows When the JSON holds an array, you usually want one table row per element. How you write it depends on the format. ### In Word Use a table with two marker rows around the row that repeats: | Row | Contents | | Headers | Description | Qty | Total | | Open | {%tr for r in lines %} in the first cell, rest empty | | Data | {{ r.description }} | {{ r.quantity }} | {{ r.total }} | | Close | {%tr endfor %} in the first cell, rest empty | The open and close rows do not appear in the PDF: they are consumed. The data row is duplicated once per element. The name after for is yours to choose and is what you use in the data row. If the table can grow long, mark the header row as repeating. In Word: right-click the row → Table properties → Row tab → tick Repeat as header row at the top of each page. Without it, once the table crosses a page the second page starts with data rows and no column names. It is the most common oversight when a table that used to hold one row becomes repeating. ### In HTML ``` {% for r in lines %} {{ r.description }} {{ r.quantity }} {{ r.total }} {% endfor %} ``` Put the header row in a and it repeats across pages by itself. #### A long table: fix its layout By default a browser, and our renderer, measures every cell of a table before placing the first row, so that each column is as wide as its longest value. On a table of a few hundred rows that measuring is most of the work. Tell it the widths instead and it lays the table out row by row: ``` ... ``` On a 300-row table this makes the PDF about a quarter faster, with the same content. A value longer than its column wraps inside it instead of widening the column. Validate reminds you when a table with a repeating row has no fixed layout. ### In Excel In column A of the row that opens the block write {% row for r in sales %}, and in column A of the closing row {% endrow %}. The rows between are cloned per element. ``` A1: Sales report A3: {% row for s in sales %} A4: {{ s.product }} B4: {{ s.quantity }} C4: {{ s.total }} A5: {% endrow %} ``` An empty array produces zero rows, and the markers disappear either way. Whole-page feature matrix by format: What each format can do. ## Conditional parts Source: https://app.doconure.com/docs/template-conditionals Showing a paragraph, a row or a whole section only when the data says so. ## Conditional parts Four words — if, elseif, else, endif — and where you put the tag decides how much disappears. ``` {% if invoice.total > 1000 %} …this block… {% elseif invoice.total > 500 %} …or this one… {% else %} …otherwise this… {% endif %} ``` | Where the tag sits | What disappears | | Alone in a paragraph | The whole block between it and its endif. No blank line is left where the tags were. | | Alone in a table cell | The whole row. Put the opening tag in one row and the closing tag in another, with the rows to hide between them. | | Inside a sentence | Only those words: Dear {% if vip %}valued {% endif %}customer. | You no longer need the tr and p prefixes. They still work if you already use them — a tag you prefixed by hand is left exactly as written — but a plain {% if %} alone in a cell already hides the row. ### What a condition may say | Write | True when | | invoice.total > 1000 | a number is bigger. Also <, >=, <= | | customer.country = 'IT' | the values match. Case is ignored, and '10' equals 10 — the same leniency as field merging | | invoice.total != invoice.paid | two of your own fields differ | | order.status in ['shipped', 'delivered'] | the value is one of a list | | exists customer.vat | the field was sent at all | | empty notes | the field is missing, blank, zero or an empty list | | a and b · a or b · not a | combined, with brackets where you need them | A field that was never sent makes a condition false rather than failing the document. So do 0, null, an empty string and an empty list — which is what {% if discount %} relies on. A hidden section says why. When a condition comes out false, the generation records the reason with the value it saw — invoice.total (800) > 1000 is false — and it appears on the document's page in the console. That is usually the whole investigation. Conditions outside the list above are still accepted and handed to the template engine as written; they simply cannot produce that sentence. ## Making a value fit the space you left for it Source: https://app.doconure.com/docs/template-formatting The formatting suffixes: truncation, padding, alignment and case, applied in the template rather than in your code. ## Making a value fit the space you left for it A placeholder may carry a formatting code after :::. It is always optional and only changes how the value is printed — never the value in your JSON. | Write | What it does | | {{ contact.phone:::l:80 }} | At most 80 characters. Cuts at the last whole word and adds an ellipsis, which counts towards the limit — so the result never exceeds the room you measured. | | {{ address:::one }} | Collapses line breaks and runs of spaces into single spaces. For a three-line address that has to sit on one line. | | {{ iban:::wrap:20 }} | Lets a long unbroken string — an IBAN, a URL, a product code — break across lines instead of running out of its column. Invisible: it adds break opportunities, not characters. | | {% for line in invoice.lines:::max:50 %} | On a loop: take at most 50 items. A table that receives five thousand rows becomes two hundred pages otherwise. | Codes may be chained: {{ notes:::one:::l:120 }}. A code we do not recognise is ignored and the value is printed unchanged, with a warning on the generation — a misspelled code costs you the formatting, never the document. There are no bold, italic or underline codes. Make the placeholder itself bold in Word and the value comes out bold — the editor already does this, and a code would only duplicate it. Number and date formatting: values are printed exactly as received. If you need 1,234.56 € or 10/09/2026, format them in the system that builds the JSON and send strings. Dates in particular: the document speaks your customer's language, and the service does not presume to know which. ### Drawing a chart from the payload A bar, line or pie chart, drawn from numbers in the payload. Works in Word and HTML templates, not in Excel. ``` {{ sales:::chart }} {{ sales:::chart:120 }} ``` and in the payload: ``` { "sales": { "type": "bar", "title": "Revenue 2026", "labels": ["Q1", "Q2", "Q3", "Q4"], "series": [ {"name": "Italy", "values": [10, 12.5, 9, 14]}, {"name": "France", "values": [4, 6, 7, 8]} ], "alt": "Revenue by quarter, Italy against France" } } ``` - type is bar, line or pie. A pie takes exactly one series of zero-or-more values. - Bounds: at most 6 series and 60 points (12 slices for a pie); labels and names at most 40 characters; values are plain numbers. Past a bound the chart is left out, with a warning, and the document is still produced. - The chart is drawn on our side. No address is ever fetched, and nothing in the spec can carry a style, a script or a link. - alt is the description a screen reader reads. Leave it out and one is built from the title and the data. Two or more series, and every pie, get a legend, so no meaning rests on colour alone. - A chart that cannot be drawn does not stop the document, in strict and lenient templates alike: it is left out and Validate and the generation's warnings say why. A field that is absent is the usual missing-field rule. ### Putting a picture from the payload into the document A photo of a receipt, a signature scan, a product shot — anything the payload carries rather than something already in the template. ``` {{ receipt:::img }} {{ receipt:::img:120 }} ``` and in the payload: ``` { "receipt": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ..." } ``` It must be a data URI. Bare base64 is refused, and the reason is worth one sentence: a long run of letters is valid base64, so an ordinary text field would be mistaken for a picture and silently replaced by nothing. The data:image/jpeg;base64, prefix is one line to add in any language and removes the guesswork. Send it at full size. Each image is checked to be a real PNG, JPEG or WebP and then resized down to fit a printed page — a 4000-pixel phone photo arrives as about a sixth of the bytes. Resizing before you send only costs quality, and the body limit is what you would be trying to avoid: it is 10 MB of JSON, and base64 inflates by about 37%, so roughly 7 MB of photos per call. One unusable image costs that image, not the document. It is left out, the rest are placed, and the job records a warning saying how many. An expense note with ten receipts does not fail because the seventh was a screenshot of a screenshot. Lists work the way every other list does: ``` {%p for picture in pictures %} {{ picture.label }} {{ picture.image:::img:150 }} {%p endfor %} ``` Word templates only. In an HTML template there is nothing to place an image into, and you do not need this filter: put the same data URI straight into and it works. Using :::img in HTML leaves the space empty and says so in the job's warnings rather than failing quietly. A URL is never fetched, in either engine. A link supplied by a caller and dereferenced by our server is a way into our own network, and that door is deliberately shut. A misspelled code is one row in Common mistakes. ## Common mistakes Source: https://app.doconure.com/docs/template-mistakes The handful of things that go wrong in real templates, starting with the placeholder Word silently split in half. ## Common mistakes | Symptom | Cause | Fix | | The PDF shows {{ customer.name }} as text | Word split the placeholder internally, because it was edited mid-tag | Delete the whole placeholder and retype it in one go, with no corrections in the middle | | The PDF shows [[!NAME!]] or {NAME} | The template uses another product's syntax (PDF Butler, Portwood) | It needs converting. Our syntax is always {{ }} | | A value is blank in the PDF | The template reads a field your JSON does not have — possibly a misspelling | Open the generation's Details page: sent but never read lists the typo. Or use Test with JSON, whose pre-filled payload shows every field the template expects | | The table repeats only once | The {%tr for %} / {%tr endfor %} pair is missing, or both sit in the data row | They belong in two rows of their own, one above and one below the data row | | From page 2 the table has no headers | The header row is not marked as repeating | Table properties → Row → Repeat as header row | | NO_ACTIVE_VERSION with a correct id | No version of the template is active | Open the template here and press Activate on the version you want | | None appears in the PDF | A field is null and gets printed instead of hidden | Wrap it in {% if field %}…{% endif %} | | A whole section is missing | Its condition came out false | The generation's Details page names the condition and the value it saw, e.g. invoice.total (800) > 1000 is false | | A value is cut off with a … | A :::l code on that placeholder | Raise the number, or drop the code if the space was bigger than you thought | | The table is shorter than the data sent | A :::max on the loop | Raise it. The generation's page says how many items were left out | | The PDF uses a different font from the Word file, or a line breaks in another place | The server has free fonts only: a font it does not have is replaced by a similar one | Upload tells you which fonts are replaced. Use Arial, Times New Roman, Courier New, Calibri or Cambria to keep the layout exactly: Fonts in your Word template | | A Word checkbox does not print, or sits on the next word | The box glyph came from a font without a proper one (fixed in 1.3) | Nothing to change in the template: the boxes are now drawn in DejaVu Sans | | A formatting code did nothing | The code is misspelled — unknown codes are ignored rather than failing the document | Validate lists every unknown code with the ones it knows | Placeholder basics: Writing a template. Repeating rows: Lists and repeating rows. Conditions: Conditional parts. Formatting codes: Making a value fit the space you left for it. ## Fonts in your Word template Source: https://app.doconure.com/docs/template-fonts Which fonts print exactly as in Word, which are replaced by a close free font and which change the look, so you can choose a font that keeps the PDF faithful. ## Fonts in your Word template The PDF is made on our servers, which have free fonts only. The fonts that come with Word — Arial, Times New Roman, Calibri, Aptos and the rest — are licensed to your copy of Word, not to a server, so we do not install them. When your template uses one, the PDF uses the free font that stands in for it. For many fonts that makes no visible difference; for a few it does. ### What happens when you upload When you upload a Word template, the template's page tells you which of its fonts are replaced and what changes. The same sentences come back from Validate. Nothing is refused because of a font: the notice is there so that the PDF holds no surprise. ### The same pages as in Word These fonts have a free twin with identical letter widths. A line breaks where it breaks in Word and the page count does not change. We say nothing about them on upload. If you want the PDF to match Word exactly, use one of these. | In Word | In the PDF | | Arial | Liberation Sans | | Arial Narrow | Liberation Sans Narrow | | Times New Roman | Liberation Serif | | Courier New | Liberation Mono | | Calibri | Carlito | | Cambria | Caladea | | Helvetica | Nimbus Sans | | Times | Nimbus Roman | | Courier | Nimbus Mono PS | | Symbol | Standard Symbols PS | | Source Sans Pro | Source Sans 3 | Fonts that are free to begin with are used as they are: Open Sans, Roboto, Roboto Slab, Lato, Montserrat, Source Sans 3, Noto Sans, Noto Serif, Inconsolata, EB Garamond, Cabin, Comic Neue, the DejaVu, Liberation and Nimbus families, FreeSans, FreeSerif, FreeMono, STIX, Linux Libertine and Courier Prime. ### Almost the same A similar design with slightly different widths. A line can break in another place, and a tight table cell can wrap one word earlier or later. You are told on upload. | In Word | In the PDF | | Aptos, Aptos Display | Carlito | | Aptos Narrow | Liberation Sans Narrow | | Aptos Mono | Liberation Mono | | Aptos Serif | Noto Serif | | Calibri Light | Carlito | | Microsoft Sans Serif | Liberation Sans | | Tahoma | Tahoma (the free Wine version) | | Verdana | DejaVu Sans | | Segoe UI, Segoe UI Light, Segoe UI Semilight, Segoe UI Semibold | Open Sans | | Consolas | Inconsolata | | Lucida Console | DejaVu Sans Mono | | Lucida Sans, Lucida Sans Unicode | DejaVu Sans | | Palatino Linotype, Palatino, Book Antiqua | P052 | | Century Schoolbook | C059 | | Bookman Old Style | URW Bookman | | Century Gothic | URW Gothic | | Garamond | EB Garamond | | Constantia | Caladea | | Wingdings | Wingdings (the free Wine version) | | Webdings | Webdings (the free Wine version) | Aptos has no free twin: Carlito is the closest, and a little narrower. If your document must match Word line for line, use Calibri or Arial instead. ### Clearly different Another look, or no equivalent at all. The letters are shaped differently and the widths are not close, so the PDF will not look like your Word file and lines or pages can break elsewhere. You are told on upload, with a suggestion: rebuild the template with one of the fonts above if the document has to stay faithful. | In Word | In the PDF | | Georgia | DejaVu Serif | | Trebuchet MS, Candara, Corbel, Franklin Gothic Medium, Franklin Gothic Book | Open Sans | | Comic Sans MS | Comic Neue | | Impact | Bebas Neue | | Arial Black | Liberation Sans (lighter) | | Gill Sans MT | Cabin | | Rockwell | Roboto Slab | | Arial Unicode MS | Noto Sans | | MS PGothic, Meiryo, Yu Gothic | Noto Sans CJK JP | | MS PMincho, Yu Mincho | Noto Serif CJK JP | | SimSun | Noto Serif CJK SC | | SimHei, Microsoft YaHei | Noto Sans CJK SC | | Malgun Gothic | Noto Sans CJK KR | A font that is in no list here becomes a default font of ours, chosen from what the file says about it (a sans font in Word gets a sans font, a serif one a serif). It counts as clearly different. ### Checkboxes and symbols The boxes of a Word checkbox (☐ ☒) are drawn in DejaVu Sans whatever font the template says, so an unticked box is printed, a ticked one sits where it should, and neither pushes the text next to it. You do not need to put the boxes in a column of their own. ### What to do - Use Arial, Times New Roman, Courier New, Calibri or Cambria when the PDF has to match Word line for line. - If you keep another font, check the PDF once with a test call: a table whose cells are tight is where a few points of width show. - Need a font that is not here? Tell us, with the template: a free font can be added to the image, and one that needs a licence can only be added once somebody holds the licence for a server. See also Writing a template and Common mistakes. ## What each format can do Source: https://app.doconure.com/docs/template-capabilities Word, Excel and HTML side by side: what each supports, how fast each renders, and which one to choose. ## What each format can do | Feature | Word | HTML | Excel | | Fields and nested objects | yes | yes | yes | | Lists / repeating rows | yes | yes | whole rows only | | Conditionals | yes | yes | whole rows only | | Images | yes | yes (embedded, not from external URLs) | yes | | Headers and footers | fields and checkboxes; loops and conditions in the body | yes, including fields | yes | | Page breaks | yes | yes | yes | ## Salesforce (Apex) Source: https://app.doconure.com/docs/salesforce A working Apex example: call the API from a Flow or a trigger and attach the PDF to the record. ## Salesforce (Apex) Minimal service class. In production move the URL and key into a Named Credential so the key is not in the code. ``` public with sharing class DocumentComposer { private static final String BASE_URL = 'https://your-host'; private static final String API_KEY = 'lpdc_xxxxxxxxxxxxxxxxxxxxxxxxxxxx'; public static Blob generateDocument(String templateId, Map data) { HttpRequest req = new HttpRequest(); req.setEndpoint(BASE_URL + '/api/v1/compose/documents/generate'); req.setMethod('POST'); req.setHeader('Authorization', 'Bearer ' + API_KEY); req.setHeader('Content-Type', 'application/json'); req.setBody(JSON.serialize(new Map{ 'data' => data, 'settings' => new Map{ 'templateId' => templateId } })); req.setTimeout(120000); HttpResponse res = new Http().send(req); if (res.getStatusCode() != 200) { // Branch on the code, not the message. Map err = (Map) JSON.deserializeUntyped(res.getBody()); throw new CalloutException( 'DoConure ' + err.get('code') + ': ' + err.get('message')); } return res.getBodyAsBlob(); } } ``` Remember to add the host under Setup → Remote Site Settings, or use a Named Credential, otherwise Salesforce blocks the callout. Composing and sending for signature in one call — POST /api/v1/flow — is the same shape, just a richer body and a different endpoint. The signature goes where the template has {{ signature.A }}, so the signer needs no coordinates (Where the signature goes on the page: a tag in the document, or fractions of the page from the top-left as the alternative): ``` public static Map composeAndSign( String templateId, Map data, String signerEmail, String signerName, String requestKey) { // one per piece of work, e.g. 'contract-' + record.Id // Placed by the template's {{ signature.A }} tag. Without a tag, give // 'fields' => [{'kind' => 'signature', 'page' => 1, 'x' => 0.1, 'y' => 0.78}]. Map signer = new Map{ 'email' => signerEmail, 'fullName' => signerName }; Map body = new Map{ 'data' => data, 'settings' => new Map{ 'templateId' => templateId }, 'signature' => new Map{ 'subject' => 'Please sign', 'signers' => new Map{ 'A' => signer }, // Told when it closes, at your Apex REST class, authenticated by // the "prod" callback environment (OAuth2 client credentials). 'callbackUrl' => new Map{ 'env' => 'prod', 'onSuccess' => '/doconure/ok', 'onError' => '/doconure/ko' } } }; HttpRequest req = new HttpRequest(); req.setEndpoint(BASE_URL + '/api/v1/flow'); req.setMethod('POST'); req.setHeader('Authorization', 'Bearer ' + API_KEY); req.setHeader('Content-Type', 'application/json'); // Retried Apex callouts (a platform event replay, a batch retry) must not // compose and email a signer twice. The key names the work, not the // attempt: the same record gives the same key, so a retry is recognised // (see /docs/flow). A fresh random key per attempt would defeat it. req.setHeader('Idempotency-Key', requestKey); req.setBody(JSON.serialize(body)); req.setTimeout(120000); HttpResponse res = new Http().send(req); return (Map) JSON.deserializeUntyped(res.getBody()); // Or poll GET /api/v1/flow/{flowId} until output.status is not "pending". } ``` ### Being called back in your org Apex REST on your org's own domain wants an OAuth token, so an unauthenticated callback cannot reach it without a Site and a guest user. Instead, add a callback environment in the console (OAuth2 client credentials against your org's token URL, with an External Client App's consumer key and secret) and send env and paths, as above. We fetch the token, keep it until it expires and send it with every callback, beside our HMAC: How do I make our callbacks authenticate at my system? ## Python Source: https://app.doconure.com/docs/python A working Python example, with the error handling you actually want around it. ## Python ``` import os import requests BASE_URL = "https://app.doconure.com" API_KEY = os.environ["DOCONURE_API_KEY"] def generate(template_id: str, data: dict, version: int | None = None) -> bytes: settings = {"templateId": template_id} if version is not None: settings["templateVersion"] = version payload = {"data": data, "settings": settings} r = requests.post( f"{BASE_URL}/api/v1/compose/documents/generate", headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}, json=payload, timeout=120, ) if r.status_code != 200: err = r.json() raise RuntimeError(f"{err['code']}: {err['message']}") print("rendered with template version", r.headers["X-Template-Version"]) return r.content ``` ## Signing in, and getting back in Source: https://app.doconure.com/docs/account-recovery What a second factor is, why recovery codes exist, and what to do the day the email address on the account stops working. ## Signing in, and getting back in Every account needs two things to sign in: a password and a second factor. This page is about the second one, and about the day something that normally works does not. ### The second factor is not optional A password on its own does not sign anybody in, and there is no setting that turns that off — for our staff and for yours. After the password is accepted you are asked for one of two things, and the account chooses which: | Factor | What it is, and when to prefer it | | A code by emailthe default | Six digits sent to the address on the account, valid for ten minutes. Nothing to install and nothing to set up, which is why it is what you get without doing anything. It depends on your email working. | | An authenticator app | Six digits generated on your phone by 1Password, Aegis, Authy, Google Authenticator, Microsoft Authenticator or any other TOTP app (the open standard, RFC 6238 — nothing here depends on any one company). No network, no delivery, nothing to wait for. Set it up from your profile and it replaces the emailed code — you will not be asked for both. | Operator accounts at DoConure are the one exception: they always get the emailed code, and an authenticator app is a third step added on top rather than a replacement. That is a rule about our own staff, not about yours. Every way in goes through this, including the ones that look like shortcuts: - Google proves who you are, as a password does — it is the first step, never the whole sign-in. - There are no password-less accounts. Every account has a password and a second factor (decided 2026-10-08). An account that used to sign in with an emailed link is sent, the next time it signs in, a link to choose a password; from then on it signs in like every other account, and keeps the second factor it had — the authenticator app if it set one up, the emailed code otherwise. - A password reset or an invitation link lets you choose a password and then takes you to the sign-in page. It never signs you in by itself. - A new account is signed in once, straight after the emailed verification link you clicked to create it. Every sign-in after that asks for the second factor. - An API key is not a sign-in and is not affected: it is for programs, not for people. #### Setting up the authenticator app - Open your profile and, under Two-factor authentication, press Set up the app. - Scan the QR code with the app, or tap the link on a phone, or type the key in by hand. The QR code is drawn on the page itself; nothing is fetched from anybody else. - Type the six digits the app now shows. Nothing changes until that code is accepted, so a mistake cannot lock you out. - You are shown ten recovery codes, once. Keep them somewhere that is not your phone. To change phone, turn the app off from the same card (your password is asked for) and set it up again. Turning it off sends you back to the emailed code; it never leaves an account with nothing. Changing how you sign in ends every other session you had open: you are signed out of the other browsers and devices. #### I lost my phone - Sign in with your password and type a recovery code in the box where the app code goes. It works once. - No recovery code left? On that same page press I lost my authenticator — email me a code instead. A code is sent to your address and you sign in with it. This is a button, never a link in an email, and it is limited and recorded like every other code. It is not offered to operator accounts, whose app is an extra step: they use a recovery code or ask the platform owner. - Once in, set the app up again on the new phone and make a new set of recovery codes. #### Limits, so that nobody can wear an account down Sign-in is rate-limited in ways that never reveal whether an address has an account: the same message is shown for an address that does not exist as for one that is locked. - Five wrong answers lock an account for 15 minutes. - Too many failures from one address, against too many different accounts from one address, or from one network, are refused for the same 15 minutes — that is what stops one password being tried against thousands of accounts. - Sign-in codes by email are limited per account per hour, and codes by text per account and per address per day. Past the limit the code you were already sent stays valid for its ten minutes. The limits are settings of the platform (Security section of the operator settings), not hard-coded numbers. ### Passwords expire A password is good for 90 days by default. The number is set by whoever administers the platform and can be any value, or nothing at all. When it runs out, signing in does not fail — you are sent a link to choose a new password. A warning appears a week before, so in the ordinary case you change it when it suits you rather than when you are trying to get something else done. An account can also be given an explicit expiry date, which overrides the rolling window in either direction. That is for cases the window cannot express: a contractor whose access ends on a date, or a password that has to change by Friday because somebody read it over a shoulder. ### Recovery codes — the day your email stops working This is the part worth reading before you need it. Your email address is doing three jobs at once. It is how the account is named, it is where the second factor is sent, and it is how a forgotten password is reset. They all rely on the same mailbox, so if that mailbox stops working, all three stop together — and there is no partial state. You cannot sign in, and you cannot recover the thing you would need in order to sign in. It happens for ordinary reasons: somebody leaves a company and IT closes the address, a domain moves provider, a mailbox fills up, an account was created against a personal address that has since been abandoned. Password expiry makes it a question of when rather than if, because the reset link is sent to that same address. Recovery codes are the answer, and they are the only one that needs nothing to work. No mailbox. No phone. No network. No third party. Ten single-use codes, printed once, that each stand in for the second factor. #### Making a set - Open your profile from the menu at the bottom of the side bar. - Find Recovery codes and enter your password. It is asked for because an unlocked screen must not be enough to mint a credential that skips the second factor. - The codes are shown once. Copy them then, because only their hashes are kept — nobody at DoConure can read them back to you. Not support, not an administrator, not whoever holds the database. Making a new set replaces the old one. A set somebody printed and lost is worse than no set at all, because they believe they have one. Making a new set also ends every other session you had open, the same as when a coordinator suspends somebody or changes how they sign in. #### Using one Sign in with your password as usual. When you are asked for the code that would have been emailed, type a recovery code instead. It works once and is then gone; the others keep working. #### Where to keep them Somewhere that is not your inbox and not the phone with the authenticator app on it — those are the two things that might be the thing that is unavailable. Printed, in a drawer, is a perfectly good answer. A password manager on a different device is another. ### What we cannot do for you We cannot read your recovery codes back to you, and we cannot move an account to a different email address on a support call. Both would mean that a convincing phone call is enough to take over an account, which is a worse failure than the one it would solve. So the honest advice is short: create a set of recovery codes now, while everything works. It takes a minute and it is the only thing that helps on the day nothing else does. ## The audit trail Source: https://app.doconure.com/docs/audit-trail Every action and every sign-in, chained so a change cannot pass unnoticed — how to read it, filter it, export it and check it. ## The audit trail Everything done in your organisation, and every attempt to sign in to it, recorded as it happens and chained so that a later change cannot pass unnoticed. It is at Audit trail in the side bar. ### What is recorded | Trail | What it holds | | What was done | Actions against the product: a template created, a version activated, an API key issued or revoked, settings changed. Who did it, what they did it to, and when. | | Who signed in | Sign-ins, and sign-ins that failed — including attempts against an address that has no account, which is exactly the pattern a trail exists to show. | A signature has its own trail, kept with the envelope rather than here: every view, code sent, signature and decline, with the address and the browser. It appears on the document's own page and travels in the evidence bundle. ### Why it can be relied on Every entry carries the fingerprint of the entry before it. Change one row and its own fingerprint no longer matches; delete one and the next row points at something that is not there; reorder two and both break. Press Check the chain and the whole thing is walked, oldest to newest. It reports either that it verifies and how many entries were checked, or the first break and where it is — only the first, because after one break everything later is unverifiable anyway and a list of two hundred consequences hides the one cause. It is deliberately not run on every page load: it reads the whole chain, and a page that gets slower as the trail grows is a page nobody opens. What this proves, precisely. It proves that nobody has edited, removed or reordered an entry since it was written. It is tamper-evidence, not tamper-proofing: somebody with direct access to the database server could in principle recompute an entire chain. Two things stand in the way: the trails are append-only in the database itself, so an ordinary edit or deletion of an entry is refused; and once a day the newest link of each chain is sent off the server by email, so a chain rewritten from the start no longer contains a link that was copied out before it. Entries written after the last copy are covered by the next day's. The date has a third witness that is not ours. Once a day the fingerprint of the newest link (and nothing else: no entry, no name) is sent to a time-stamping authority (RFC 3161) and the signed answer is kept and copied with the daily backup. Check the chain then also says whether every stored timestamp still matches a link the chain contains. It is a free, non-qualified authority: good evidence of when the chain had that link, not a qualified timestamp with a legal presumption of date. ### What a change used to be A change to an existing value records what it was and what it became: the Change column of the page, and the before and after columns of the export (as JSON, only the fields that changed). The old value is part of the entry's fingerprint, so it cannot be altered afterwards without breaking the chain; an entry written before this existed has no such columns and verifies as it always did. Nothing sensitive is written there. A password, a token, a key or a secret is never recorded, only the fact that it changed; a person's details (a phone number, an address, a name, a free-text note or reason) are shown as [personal data]; a long value is cut. A change made by creating or deleting something records that event, not a before and after. ### Answering a question with it An auditor rarely browses. They ask a question — what did this person do between these dates?, show me every change to this template — so the page is a filter and an export rather than a dashboard. - Containing matches a name, an action, an entity or an address. - From and To take whole days, and To includes the day you typed. - Results are newest first, a hundred to a page. ### Exporting it Export what is shown gives you a CSV of every row the filter matches — not just the page you are looking at. A file that quietly stopped at a hundred would be evidence with a hole in it that nothing declared, and somebody would hand it over without reading. The export is oldest first, because that is the order things happened in and a reviewer reads forwards. Two columns travel with it that are worth keeping: | Column | Why it matters | | chain_index | The entry's position in the chain. Gaps are meaningful. | | entry_hash | The fingerprint. This is what lets somebody check the file against the live trail later — without it the export is a list of claims, and with it a list that can be verified. | ### How long it is kept As long as the records it describes. Signature evidence is kept for ten years by default and your organisation can choose less, never more; documents themselves expire after a year. When evidence is finally swept, the entries go with it and the fact that something existed and was not altered survives. ### Who can see it Your organisation's coordinators — not other customers, and not your own ordinary users. Our platform operators can open it as well when they work inside your console to support you. Times on the page are written in your organisation's locale. The From and To filter count whole days in that zone, and the CSV export writes every time in it as ISO 8601 with its offset — the same instant as the API's UTC, so a file can be compared with its answers without guessing a zone. The trail names colleagues and the addresses that tried to sign in, which is not general reading. ## Credit, prices and VAT Source: https://app.doconure.com/docs/credit-and-vat What a credit is worth, what each operation costs, how buying works and the most one purchase may cost, which VAT applies to you — 22% in Italy, 0% abroad, or the regime we set — where your VAT number stands, what locks after your first invoice, how a VAT correction works, what happens to your credit if you stop using the account, and the card saved for a plan: what we keep and how to delete it. ## Credit, prices and VAT One wallet, one rate per operation, and an optional yearly plan. This page says what a credit is, what each thing costs, what a plan includes, what happens when you buy, and which VAT applies to you — which depends on where you are and whether you are a business. ### What a credit is A credit is a unit of work, and it is prepaid. You buy credit, your integration spends it, and the balance goes down. There is no monthly fee and nothing expires. A yearly plan is an option on top, never a replacement: the credit stays where it is. A credit is worth 10 cents, whatever amount you put in. That is the reference price and it does not change with the size of the top-up: a discount, if there is ever one, arrives as more credits per euro rather than as a cheaper credit, so the number above stays true. ### What each operation costs Your own rates are on your dashboard, under What your credit buys. They are your rates and not the platform defaults — if yours were negotiated, that table already says so. The defaults are: | Operation | Credits | Notes | | One document composed | 2 | Whatever the format, whatever the page count. | | One signature request sent | 10 | Per envelope, not per signer. A flow (compose and send for signature in one call) is the two together: 12. | | One SMS to a signer | 2 | Per one-time code the SMS gateway accepted, charged to the organisation that sent the envelope. A code the signer asks for again is a new message and is charged again; a message that did not leave (an unregistered country, a gateway that refused it) is not charged. | | Qualified seal | 2 | Only if your organisation holds it, and only from a qualified provider. On a composed document it is paid with the document; on an envelope it is paid when the envelope is sent, with the signature, although it is applied when the envelope completes. | | Qualified timestamp | 4 | Always together with the seal (seal and timestamp: 6), paid at the same moment. | | Email to a signer, and everything else | 0 | Free, and still counted where the event is recorded. | Everything is paid before the work starts: a document when it is requested (and given back if it fails), an envelope with its seal and timestamp when it is sent, an SMS code when it leaves. What a problem leaves unused comes back, to the credit it came from: a failed document, and an envelope's seal and timestamp when it is cancelled, expires or is declined, or when they cannot be applied (also when the provider's answer is lost). Each return is a refund line in your ledger and a line in your audit trail with its code (Qualified seal and timestamp). The signature of a sent envelope, and a document that was delivered, are not given back. A failed generation is not charged. Work that did not produce a document does not consume credit. One exception is worth stating because it surprises people: a dataless generation is charged. The work was done — not keeping a copy is a privacy choice, not a discount. #### What a call cost, line by line Every answer carries output.credits: used is what the call cost, left what the wallet holds now, and items what used is made of — one line per operation and payer, read off the same charges: ``` "credits": { "items": [ {"unit": "document", "quantity": 1, "credits": 2, "paidBy": "credit"}, {"unit": "signature", "quantity": 1, "credits": 0, "paidBy": "plan"}, {"unit": "seal", "quantity": 1, "credits": 2, "paidBy": "credit"}, {"unit": "timestamp", "quantity": 1, "credits": 4, "paidBy": "credit"} ], "used": 8, "left": 1240 } ``` paidBy is credit (the wallet) or plan (a counter of your plan, at zero credits); the credits of the lines add up to used. On an envelope the SMS codes join the list as they are sent, so read the envelope again for the final figure. A test call (isTest) lists nothing. ### Yearly plans A plan is an allowance for a year, paid in advance, bought from Billing while it is on sale there. While it is active it comes before the credit: what it includes is taken from it first, and the credit pays the rest at your rates. | EarlyBird | Includes, for one year | | Signatures | 1,200 envelopes | | SMS codes | 2,400 (two per signature) | | Documents | 5,000 compositions | | Price | €899 a year, VAT excluded — €2,680 at the prepaid rates | - Each counter is apart. A flow takes one signature and one document; a composition takes one document; an envelope one signature; every SMS code one SMS. - When a counter runs out, what it counts is refused with SUBSCRIPTION_ALLOWANCE_EXHAUSTED (1816) — unless you turned on Pay with credit when a counter of the plan is used up on the billing page; then it is paid in credit. An SMS code to a signer is never refused: past its counter it is paid in credit. - Seal and timestamp are not in the plan: they are paid in credit. - A plan's price is fixed: a renewal costs what the period cost, while the next period ends within the plan's validity (EarlyBird: by 31 December 2030). A new price list is a new plan, which you choose. - Upgrade at any time, until five days before the end, to a dearer plan on sale with at least as much of every counter: you pay the difference between the two prices, what you used stays counted, the limits rise and the end date stays; from then on it renews as the new plan. Not when the next period is already paid. - A plan in force does not move down or change before its last thirty days. You can cancel at any time: the plan runs to its end, then the account is on prepaid credit; you can take the cancellation back until five days before the end. - In the last thirty days, until five days before the end, you confirm the plan, change it for another one on sale, or cancel. Thirty days before the end your coordinators get an email saying what happens and at what price; reminders follow at fourteen, seven and one day until you decide. - At the end of the year a plan that renews, paid by card, is charged from five days before the end, once a day until it succeeds; if it never does, the account goes back to prepaid credit and the plan chosen is lost. Paid by bank transfer, the pro-forma arrives thirty days before the end, to pay within seven days. A one-off plan, or one whose next period would end after its validity, does not renew: choose another one, or the account goes back to prepaid credit. A payment confirmed after the end does not revive the plan: we look at it and activate the period or refund it. The counters start again from the full allowance and your coordinators get an email saying so. What was not used does not carry over; the credit always stays. - If the saved card will not be valid on the day of the charge, Pay the next period now with another card on the billing page moves the renewals to the new card. - Your coordinators are emailed when 20%, 10% and 5% of a counter are left. A plan for larger volumes can be agreed with us; its numbers are in your agreement and on your billing page. An agreed plan can be managed directly by us: Billing then shows no payment page for it, nothing is charged there, and once its allowance is used up the service stops until we add a block with you. #### Your card We keep no card. The card number and its security code are typed in Revolut's own card form and never reach our servers. For a plan that renews we keep a token Revolut gives us, the last four digits and the expiry: enough to say the card ending 4242, expires 12/30 and to warn you before a renewal. - At every payment (buying a plan, an upgrade, paying the next period now, a top-up) Billing offers Use the saved card or Add a new card. A new card paying a plan replaces the saved one once the payment has gone through, and the old card is deleted at Revolut. A top-up paid with a new card is not saved. If the saved card is declined, or your bank asks you to confirm it, add a new card instead. - Delete card (Billing, Plan, Card; coordinators): the card is deleted at Revolut and renewal goes off. Your coordinators are emailed, each in their language: the plan will not renew on its end date, the account then goes back to prepaid credit with the credit you hold, and a top-up will need a card again. In the last thirty days, until five days before the end, you can turn the plan back on and pay with a new card. - A card that expires before the renewal: in the last thirty days your coordinators get one email saying so; paying the next period now with another card moves the renewals to it. - When an organisation is closed or anonymised, the card saved at Revolut is deleted too. ### Credit is not the only ceiling Your organisation also has limits: API requests per minute, pages in one document, the size of what you upload, test calls per day, how long download links and uploaded files last, and the most one credit purchase may be (€1,000.00, VAT excluded). They have their own page in the console, Reference → Limits, with the values that apply to your organisation today; a program reads the same from GET /api/v1/usage. A limit is not a purse. Reaching one is refused rather than charged, and buying more credit does not raise it — that takes a conversation with us. Both failures answer HTTP 429, so tell them apart by the code, never by the status: running out of credit is QUOTA_EXCEEDED (not retryable until you buy), a rate limit is RATE_LIMIT_EXCEEDED (retry after Retry-After). #### When credit runs low Your coordinators are emailed as the balance crosses 20%, 10% and 5% of what it held after your last top-up. The mail puts the figure that matters in bold — the percentage left and how many documents you can still produce at your rate — so it can be read at a glance. Buy more from Billing → Buy credit, or ask us for an invoice. ### Buying From Billing → Buy credit, if you hold the billing capability. Four things are worth knowing before you get there. #### The billing details come first You cannot buy until the invoicing details are complete. That is not bureaucracy for its own sake: an Italian invoice goes through the Sistema di Interscambio, which rejects a document that does not say where to deliver it — so a payment taken without a complete profile is money we cannot invoice. The page names the fields that are missing and links to the form. If your organisation is outside Italy, the SdI fields are not asked for at all. A codice destinatario is an Italian thing. #### The amount - Whole euro, from €10, and the amount you type is net of VAT. The field says so. - One purchase is at most €1,000.00, VAT excluded — €1,220.00 to pay with Italian VAT at 22%, €1,000.00 with none. A larger one is refused before any payment is asked for, with PURCHASE_AMOUNT_TOO_LARGE (1815). For more, make several purchases, or contact us (or write to us from the console): larger volumes come with a conversation about the volume itself, a service level and an invoice schedule, and we can put the credit on the account by hand. - Credit is bought on the amount before tax. Tax is added afterwards and shown before you pay. - Credits round up and are never fractional. An amount that works out at 98.1 credits buys 99. #### What happens then An order is opened and priced. Everything about the price — net, rate, tax, total, and the VAT treatment below — is copied onto that order and never looked up again, so a rule that changes next year cannot rewrite an invoice already issued. Credit reaches the wallet when the order is paid, and exactly once however many times a payment is retried. ### VAT We are established in Italy and what we sell is an electronically supplied service. Which VAT applies depends on where your organisation is, taken from your billing profile, and on any VAT regime we have set for it. Your billing page shows it, read-only, and the purchase page shows it again before you pay. | Your organisation | What is charged | Why | | In Italya company or professional | 22% | Italian VAT, ordinary rate. | | In another EU countrya business, with its VAT number | Nothing (0%) | Reverse charge: you account for the VAT yourself. The invoice carries inversione contabile — reverse charge. | | Outside the EU | Nothing (0%) | The supply is not subject to Italian VAT. The invoice carries operazione non soggetta a IVA, art. 7-ter DPR 633/72. | | A regime we set for your organisationin any country | The rate we set | After checking, our finance team may set another regime — exempt (0%), split payment for a public body, or another rate — with the wording the invoice carries. Your billing page shows it. | #### Validating your VAT number If you are a business in another EU country you can validate your VAT number on Billing → VAT. We check it against VIES, the European Commission's register, and keep what it says as evidence for your invoices. It does not change what you are charged: the rate is the one in the table above whether the number validates or not, and a purchase never waits for the register. Three things to expect: - Type the number without the country prefix, or with it — either works, we strip it. Greek numbers begin EL, not GR, and we handle that for you. - "We could not reach the register" is not "your number is invalid." VIES depends on each member state's own system and one of them is often down. Nothing is recorded about the number when that happens: the profile shows Being checked, we try again at 15 minutes, 1 hour, 4 hours, 12 hours and 24 hours, and only then hand it to our team. - Changing your VAT number, country, tax code or type clears the validation. The stored answer is about one number in one country. #### Where your VAT number stands The billing page shows one of five states. The state is a record of the check; it neither changes your VAT nor stops you buying. | State | What it means | | Not verified | Nobody has checked it yet, or it changed since. | | Being checked | We asked and the register could not answer; we keep trying. | | Verified | The register confirmed it, or our team verified your identity by hand. | | Not recognised | The register does not know this number outside Italy. Check it, or ask for a review. | | With our team | An Italian number the register does not know, an outage that lasted, or a review you asked for. You can keep buying meanwhile. | #### Once you have been invoiced After your first completed purchase — or once our team has verified your identity — the VAT number, country, tax code and type are locked: an invoice names them. Your address, delivery details and name still change freely. To change a locked detail, ask for the change on the billing page with the reason; our team reviews it, the number is checked again, and the old and new values are kept on the audit trail. #### If VAT does not apply to you Purchases are always charged at the rate of your account, shown read-only on the billing page: 22% for an Italian organisation, 0% for one in any other country, unless we have set another regime for it. If a different VAT regime applies to you — an association, a flat-rate regime, a public body — tick My VAT treatment is different on the billing page and tell us what applies and where it is written. That message changes nothing you are charged. Our finance team checks it and, if it holds, sets the regime and its rate for your account (exempt, split payment at the rate we set, or another rate with its note) and customer care may apply additional credit for what was charged. The regime applies to later purchases; an order already made keeps what it was sold under and is corrected, if at all, as described below. #### If Finance corrects the VAT of an order If our finance team finds that the VAT treatment of a paid order was wrong, it corrects it and you are told by email with the reason. What you paid does not change; the credit that order bought is recalculated on it, because credit is sold net of VAT. A correction against you takes credit back (first from that order's own credit); one in your favour adds it. Your order keeps showing what was sold, with the correction listed beneath it. A correction can leave your balance below zero. Then your account stays, the sandbox keeps working and you can still buy credit, but anything that costs credit waits for a top-up — and the next credit you receive, bought or given, first covers the difference. #### What we keep, and why When a validation succeeds we store the date, the result, the name the register holds and the consultation number the service returns. That last one is the part that matters: the register keeps no history, so the consultation number is the only way anybody can show, a year later, that the check was made and what it said. It appears on your billing page, in each order's record and on the audit trail — as evidence, never as a price. ### Refunds and unspent credit Credit is not refundable in money, except in the cases art. 8.7 of the Terms lists; credit charged in error always comes back to the same wallet. DoConure is sold to businesses only (art. 2.2.1 of the Terms); an account that declared itself a private individual before that change keeps its rules, and buying credit asks it to confirm that the credit is wanted immediately and that the account therefore gives up the fourteen-day right of withdrawal from that purchase — art. 59, comma 1, lett. o) of the Italian Consumer Code. That declaration is per purchase, and it is recorded with its timestamp. ### Referring another organisation While the referral programme is open, Billing → Refer a colleague shows your code and a link (https://doconure.com/signup?ref=YOURCODE). An organisation that registers through it is recorded as yours, and when it makes its first qualifying purchase you receive gift credit. Nothing is paid for a registration alone. - What qualifies. A real payment (never the simulation or a test organisation, on either side) of at least the minimum shown on the page, before VAT, for an eligible purchase, made inside the campaign dates if there are any. A smaller purchase does not use the referral up; a later one can qualify. - What you receive. A percentage of the credit that purchase bought, up to a ceiling per reward and a total ceiling per referrer, both shown on the page. It is credit only: it cannot be transferred or paid out, it does not expire, and it is spent after any credit you bought. - When. After an activation delay, so that a reward is not paid on money that is about to come back. A refund or a dispute during the wait holds it. - Checks. Some referrals are looked at by a person before the reward is issued. That is routine; nothing is refused automatically, and you are told only that it is being checked. - What you see. Your page lists each referral with its date, its status and the reward — never who the organisation is. ### If you stop using the account Credit does not expire while the account is in use, and what counts as use is narrow on purpose: one successful production call that spends credit. Signing in, the sandbox (isTest), buying credit, changing settings and a call that failed do not count. - Warnings. After 12 months without such a call we write to your organisation's coordinators to check in; at 18 and 23 months we warn that the account will be archived, and a last time a week before it happens. One production call clears every warning. - Archive, at 24 months. Sign-in, API keys and callbacks stop. Nothing is deleted and your credit is exactly what it was; archiving is reversible. - Coming back. Sign in with your password and ask for reactivation. A person on our team checks who is asking and, once satisfied, reactivates the account with its credit and history unchanged. Reactivating does not reset the clock, so you get a grace period (30 days by default) before the account could be archived again: use it, or produce a document. - Anonymisation. An account that stays archived for five years is anonymised, after a notice. This cannot be undone: names, addresses, users, keys and templates are removed, the remaining credit is lost and you would register again from the start. - Closing it yourself. Settings → Close this organisation does the same on request, after you have read what it means, typed the organisation's name, entered a code we email you and confirmed a last time. Only a coordinator can. - What the law keeps. Orders, invoices and billing details are accounting records: they are kept separately for the period the law requires (ten years is the working figure) and are not used for anything else. The evidence of signatures already completed keeps its own retention period. ### Where the numbers also live - Your rates: the dashboard, What your credit buys. Your limits: Reference → Limits. - The public price list: /legal/prezzi. - Every movement in and out of the wallet: Billing → credit movements, and the audit trail. ## Versions of the API Source: https://app.doconure.com/docs/api-versioning What /api/v1 promises, what never changes (codes, numbers, the answer's shape), how a deprecated address says so in every answer, and the six months before anything is removed. ## Versions of the API How the API changes, what never changes, and how you hear about it before anything you depend on goes away. ### What /api/v1 promises Every address starts with /api/v1. Inside version 1 a change is additive: new endpoints, new optional fields in a request, new fields in an answer, new values in a list that says it can grow. Your integration keeps working without a change of yours, as long as it: - ignores fields in an answer it does not know; - branches on code or number of an error, never on the message, which may be reworded; - treats an unknown value of an open list (an event type, a warning code) as something to log, not as a failure. ### What never changes | Thing | Promise | | Error codes and numbers | A code keeps its name and its number. A number is never reused for something else, even after a code is retired. See Outcomes and error codes. | | The answer's shape | One output object with one section per product (compose, sign, flow); an error is always the same envelope with code, number, message and retryable. | | Idempotency | The same Idempotency-Key with the same body never does the work, or charges, twice. | ### When something is deprecated Sometimes a better way replaces an old one. The old way is then deprecated: it keeps working exactly as before, and it says so. - Announced in the release notes and on the guide page that describes it, with what to use instead. - Marked in every answer. A call on a deprecated address carries the standard headers Deprecation (the date it was deprecated, RFC 9745) and Link: ; rel="successor-version". A deprecated field in an answer is marked in the guide. - Removed only in a new major version (/api/v2 or the product's 2.0), never inside version 1, and never earlier than six months after the announcement. From that point the answers also carry Sunset with the date (RFC 8594), and we write to every organisation's addresses. #### Deprecated today | Old | Use instead | Since | | /api/v1/documents/* | /api/v1/compose/documents/* | 2026-09-19 | | /api/v1/templates/* | /api/v1/compose/templates/* | 2026-09-19 | | /api/v1/signatures/* | /api/v1/sign/* | 2026-09-19 | | organisation.deliveryMode, thisMonth.documentsSucceeded, thisMonth.documentsFailed in GET /api/v1/usage | See Tracing a call, and watching your usage | 1.1 | None of them has a removal date: they will go with version 2, which is not scheduled. ### How you hear about a change - The release notes list every change a customer can notice, with the version it shipped in (MAJOR.MINOR.PATCH: a patch fixes, a minor adds, a major may remove). - The generated reference, /openapi.json, always describes the current version; deprecated addresses are not in it. - A change that needs something from you is also mailed to your organisation's addresses. ## Current limits Source: https://app.doconure.com/docs/limits The honest list: what is enforced, what is not built yet, and what you have to do for yourself. ## Current limits What this service does not do, or does in a way worth knowing about before you build on it. Nothing here is hidden behind a sales conversation. ### How it behaves | Limit | What it means in practice | | Generation is synchronous unless you ask for the queue | The request stays open until the PDF is ready: 0.2 to ~3 seconds for a typical document. Use a client timeout of at least 120 seconds: under load a call can wait for its turn, and the service always answers inside that time, with the PDF or a code you can retry. If you would rather not wait, ask for the queue ("queueIfBusy": true or Prefer: respond-async) and poll a job id: see When the platform is busy. Bulk composition is asynchronous by design: you submit a batch and poll a job. | | Concurrency is capped, and shared out | Rendering is limited to a fixed number of simultaneous documents. A request that cannot start straight away waits a few seconds for a slot; past that, or when the line is full, it is refused with SERVER_BUSY, QUEUE_CAPACITY_EXCEEDED or — if your own organisation already has as many documents rendering as one organisation may — TENANT_CONCURRENCY_LIMIT_REACHED. All three carry Retry-After and nothing was charged: retrying after the interval it names is the right response. Small documents are served first, but a large one that has waited moves up, so it is never starved. isTest calls are throttled before anything else (TEST_CAPACITY_TEMPORARILY_LIMITED) so that testing never slows a production call. | | The per-minute rate counter is in memory | The rate limit is enforced (see Outcomes and error codes), but the per-minute window resets when the application restarts. A deploy briefly gives every organisation a fresh window. It only ever loosens the limit, and only for a few minutes. | | Download links are signed and short-lived | A generated document is fetched with the API key and the document id, or through a signed download link you mint for it (POST /api/v1/compose/documents/{id}/download-links) and hand to a browser or to somebody without a key. A link lives 1 hour by default and may be asked for up to your organisation’s maximum (48 hours unless we set another, never more than seven days); a longer request is refused, never shortened. It can be revoked at once, or made single-use. See Signed download links. | | A request body is at most 25 MB | 25 MB is 26,214,400 bytes. Anything larger is refused at the door with PAYLOAD_TOO_LARGE (1803, HTTP 413), before it reaches the service: on /api/* as the usual JSON error body. Nothing was received, stored or charged. No limit of your organisation is above it: a template is at most 25 MB too, and the payload, an uploaded file and a bulk file have their own limits below it. A file sent as base64 inside a JSON body grows by about a third, so a template sent that way fits when it is under about 18 MB. | | One purchase of credit is at most EUR 1,000.00, VAT excluded | A larger one is refused with PURCHASE_AMOUNT_TOO_LARGE (1815) before any payment starts. Make several purchases, or contact us for a larger amount: see Credit, prices and VAT. | | Times are UTC in the API | Every time in an API answer or a webhook is UTC (ISO 8601). The console, the mails, the certificate of completion and the exports write times in your organisation’s locale: see Times and time zones. | | Number and date formatting is yours | Send strings already formatted the way they should appear. The template controls where a value goes and how much room it has, not how a number is punctuated — see Making a value fit. | ### What is not built yet | Missing | What to do instead | | JSON Schema validation is a bounded subset, and warn by default | A template version may carry a jsonSchema; a payload that breaks it is warned about (PAYLOAD_SCHEMA_VIOLATIONS) or, with payloadValidation: "reject", refused before anything is charged. Without a schema nothing is checked, and a misspelled field renders empty — but you are told: the response carries a TEMPLATE_FIELDS_MISSING warning. Keywords outside the supported subset, and remote $refs, are refused at upload: see the list. | | No MCP server | Point your agent at /llms-full.txt — see Integrating with an AI agent. One endpoint and no SDK is a small thing to integrate by hand. | | No AES or QES signatures | Refused rather than approximated: see Signature levels. What is produced is a simple electronic signature (SES), optionally sealed and qualified-timestamped, and the response says which. | ### What happens to your documents | Retention | What happens | | Generated documents are deleted after 12 months | Enforced, by a sweep that runs once a day. The record of the generation survives — what was made, from which template and version, when, how big — so your usage history and your invoices stay intact; the PDF itself goes. Fetch and store anything you need to keep. | | Test documents are deleted after 7 days | A document produced by an isTest call is real work on a real render, but it is evidence of nothing, so it is kept a week. | | What you sent is kept for 12 months | The payload of a generation (what Details shows in the console) is deleted with the same yearly sweep, and is never recorded at all when the call is dataless. | | Uploaded files are kept 30 days | An image or PDF uploaded to use by fileId is kept 30 days from the upload (unless we set another period for your organisation); uploading the same bytes again moves the date forward. GET /api/v1/usage says the period in limits.storedFilesDays. | | The words of our emails and text messages are kept 7 to 30 days | The text of a sign-in or signing code and of a confirmation link is removed 7 days after it was sent; the text of a signing mail (invitation, reminder, copy, notice) 30 days after its envelope closes; any other mail 30 days after it was sent. The record of the message (who it went to, what it was for, whether it arrived, when) is deleted 12 months after it was sent. It never touches the signature evidence below. | | Signature evidence is kept much longer | A signed envelope's sealed copy and its event trail are kept to a ten-year schedule, because that is the obligation attached to them and not a convenience. It is a separate policy with a separate sweep — see What the signer sees. | | You can ask us to keep nothing | "dataless": true composes the document and records neither the payload nor the PDF. The response is the only copy there will ever be. It costs the same: the work was done. See Composing without leaving anything behind. | Your own numbers are in your console. The per-minute rate, the largest payload, template and uploaded file, the pages in one document, the daily test pool and how long a download link may live are per organisation, and the ones that apply to you are shown under Reference → Limits rather than guessed at from this page. A program reads the same numbers in limits of GET /api/v1/usage (assetBytes, downloadLinks, storedFilesDays among them).