DoConure

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.statusMeans
pendingLinks are out, somebody has still to sign. Keep polling.
succeededEveryone signed. output.sign.signedDocumentUrl is populated.
failedIt 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.statusclosedMeans
awaiting_signaturefalseLinks are out. Keep polling.
signedtrueEveryone signed. output.sign.signedDocumentUrl is populated.
declinedtrueSomebody refused. Nothing is signed — see output.sign.recipients[].declineReason.
expiredtrueThe deadline passed with signatures outstanding.
cancelledtrueYou 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=<hmac>, 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.