Contract flows
A contract flow is a branching questionnaire — a DAG of question nodes — that you author and publish in the portal, then run with a connected person over the company-data API. A run is a turn-based, multi-party collaboration: each side answers the nodes it owns, answers are encrypted per party, and both sides route locally from the same condition rules. When the run ends it either auto-shares the gathered answers (data_only) or fills a PDF template and produces a contract the person signs (document).
This page documents the run surface — the company-data endpoints (and the SDK methods that wrap them) that drive a published flow to completion. Authoring a flow (laying out nodes, branches and the PDF template) is done in the allus portal UI, not over an API.
Open the live Service API explorer →
The model
- Flow — the published DAG you authored in the portal. Triggering a run pins the flow's latest published version, so an in-flight run is immune to later edits.
- Run — one execution of a flow against one connection. It walks the graph node by node, accumulating answers, until it reaches a terminal node.
- Party — a role in the flow (e.g.
company,customer) bound to a concreteuser_idat trigger time. A party carries a type:owner(your company, bound automatically),personorcompany(a connected customer of that type, bound through that customer's own connection); a party without a type is an older, untyped one you bind yourself. Each node is owned by a party; only that party may fill it, and the run is awaiting that party until they take their turn. - Answers are encrypted per party. Every new answer is encrypted once for each bound party — for the company via the service public key, for a person via their public key. That way each side can decrypt the answers it needs and evaluate routing locally.
- Local routing. Branching is decided by the shared
FlowConditionEvaluator(also ported into every SDK): the party whose turn it is decrypts the answers, evaluates the outgoing edges of the current node in order, and picks the first matching edge. The server only relays turns — it never sees plaintext and never decides routing. - Output mode — set on the flow.
data_only→ on completion the run auto-closes and the gathered answers are shared like ordinary request-field values (read them back).document→ the platform fills the leaf node's PDF template with the answers and produces a contract the person signs; signing completes the run. - Computed constants — a flow may declare hidden, author-only constants: named values computed from the answers (and the run's date) in the portal builder. They are never shown to any participant and are not answers, but they can drive branching and be printed into the generated document. See Computed constants.
- Participant PDFs — a document leaf's rule can take its PDF from a participant instead of a company template: a flow field's answer, or a file a customer shared on its connection.
- Plugin fields — a step may hold a plugin element: a field answered through a plugin the company added in the portal, whose outputs other fields can take a default, minimum or maximum from.
- Text with values — a step's text may show earlier answers, the customer's shared values and your company's profile values, with bold, italic and links. See Text elements and value tags.
definition graph (nodes, edges, parties) is the only non-secret part of a run.Connection addressing. A run's top-level connection_id names the primary counterparty on service-owner reads. On person and company-customer reads and action responses it names the caller's own connection to the run. Use that value for customer run actions; each entry in participants carries that party's individual connection. The SDK models preserve the value returned by the API.
Run lifecycle
- Trigger a run for a connection, binding every party to a
user_id. The run pins the latest published flow version and its status becomesawaiting_<entry node's party>. - Take turns. Whoever the run is awaiting fills the current node: decrypt the answers so far, run the evaluator to choose the next edge, re-encrypt each new answer for every party, and submit with the computed
next_node. The run flips to awaiting the next node's party. Person turns happen in the person's app; your company turns go through the company-data API below. - Complete by output mode:
data_only→ reaching a leaf auto-closes the run and shares the answers; read them via the connection detail / change feed.document→ on a generatable leaf you generate the contract: the platform fills one PDF per output document of the leaf and stores each as a contract, one copy per participant; status becomesawaiting_signature. The parties then sign along the run's one signing line, and the last signature completes the run.
You learn a run needs your turn from the flow_run_turn change event (or by polling flow-runs?status=awaiting_company); a terminal run fires flow_completed.
Run endpoints
All under /api/company-data, authenticated as your registered service client (the token resolves to your service — no service id in any path). Same JSON/XML negotiation and rate-limit behavior as the rest of the service API.
POST /api/company-data/flows/{flowId}/runs
Trigger a run of a published flow for one connection. The body names the target connection and binds every party in the flow to a user_id.
- Path:
flowId— a flow you published in the portal. - Body:
{ "target": {"connection_id": "…"} | "targets": {"<party_key>": {"connection_id": "…"}, …}, "bindings": {"<party_key>": "<user_id>", …}, "flow_version": <int>, "tag_values": {…}, "source_files": [{"source_key", "for_user_id", "file"}]? }.targetsbinds each typedperson/companyparty to one connected customer of that type (anownerparty is bound automatically and never named); the singletargetbinds every non-owner party to one customer. Every party must end up bound, throughtargets/targetor, for an untyped party,bindings.flow_versionis the version you read from GET …/published;tag_valuesis required when that version's text shows the customer's values (Text elements and value tags). The SDKs'triggerFlowRunsends both for you. source_files— the run's copies of its connection sources: exactly one staged file per (answeredconn:source a rule of the latest published version names, distinct bound user), your company's own copy included. Omit it when the flow names no connection source or none is answered.- Returns:
201with the run. It pins the flow's latest published version and starts atawaiting_<entry node's party>.
POST /api/company-data/flows/{flowId}/runs
Authorization: Bearer ey...
Content-Type: application/json
{"target": {"connection_id": "019xxxxxxxxxxxxxxxxxxxxxxxxx"},
"bindings": {"company": "019aaa…", "customer": "019bbb…"}}
| Status | error_key | Meaning |
|---|---|---|
400 | flows.bindings_invalid | No target was given, a party is left unbound, or a bound customer is not of the type its party declares. |
400 | flows.no_counterparty | The bindings name no participant besides your own company. |
400 | flows.tag_values_invalid | The version's text shows the customer's values and flow_version or tag_values is missing or malformed. |
400 | flows.source_files_invalid | source_files is not exactly the required set, or a copy was staged for another customer than the one bound to its source's party. The body carries missing ([{source_key, for_user_id, source_user_id}], the copies still owed — source_user_id is the customer bound to the source's party, whose shared file each copy is) and unexpected ([file], the files that name nothing required). Nothing is written; stage the missing copies and start again. |
404 | flows.connection_not_found | The target connection isn't connected to your service. |
409 | flows.not_published | The flow has no published version. |
409 | flows.version_changed | A newer version was published after you read it: read …/published again, recompile and retry. |
409 | flows.tag_values_stale | A tag_values entry was sealed to a key that is not the recipient's current one; stale lists those recipients (company or a user id). Re-read a customer's key and reseal; company means your client's service key is not the current one. |
409 | account.counterparty_deleted | The person has deleted their allme account. The connection and its existing runs, answers and documents are retained and stay readable, but the run may no longer be written to — the same 409 is returned by the company answer and cancel endpoints as well as by run creation. |
GET /api/company-data/flows/{flowId}/published
The flow's latest published version: {version, definition, request_field_types} — request_field_types maps each of your request-field slugs to its field type. Read it before starting a run: its version is the flow_version you send, and its definition is what you compile the run's text values from. 404 flows.not_found, 409 flows.not_published.
POST /api/company-data/flows/{flowId}/run-files
Stage one sealed copy of a connection source for a run start. Body: {"source_user_id": "<the customer whose shared PDF this copies>", "value": "<{\"_enc\":1,…} sealed to ONE bound user>"} — source_user_id is the customer bound to the source's party (a refused start's missing[].source_user_id); the copy is stored in that customer's home region, and the trigger accepts it only for a source whose party is bound to that customer. Returns: 201 {"file"} — name it in the trigger's source_files. A source_user_id that is missing or not a connected customer of the flow's service is refused 400 flows.source_user_invalid; a value over the upload budget 400 documents.too_large.
GET /api/company-data/flow-runs
List the service's runs, filterable by status. Use ?status=awaiting_company to find runs waiting on your turn.
- Query:
?status(e.g.awaiting_company,awaiting_customer,awaiting_signature,completed). - Returns: the matching runs (id, flow, status, current node, …).
GET /api/company-data/flow-runs/{runId}
One run in full — everything you need to render and route the current turn.
- Returns:
id,flow_id,flow_version(the pinned version),service_id,connection_id(the PRIMARY counterparty — the first non-owner party in the definition's own party order),company_user_id,bindings,participants(every party the run binds, the owning company included — see below),status,current_node,output_mode,parties([{key, label}]),answers([{slug, for_user_id, value}]),definition({entry, parties, nodes, edges, output_mode, constants}—constantsis the flow's list of computed constants), andreference_date(the immutable UTC dateYYYY-MM-DDstamped when the run was triggered — the value oftodayin every constant, deterministic for the run's whole life; see Computed constants), andprivate_slugs(see below). owner_tag_valuesandtag_values— the run's text values, fixed at start: your company's profile values the text names (plaintext{"party.field": {v, t}}), and your sealed set of the customer's values ({public, public_tags, private}, sealed to your service key). Bothnullon a run whose text names none.source_files— your company's own copies of the run's connection sources,{"<source_key>": "<file>"}({}when none). Read one withGET …/source-files/{sourceKey}.private_slugs— the sorted list of slugs whose answers the answering client markedsource_private: the value came from a private source. It is metadata, never a value, and every party of the run sees it. A value in it is never sent to a plugin by anyone but the party it belongs to (Plugin fields).participants[]— one entry per party the run bound, each{party_key, person_user_id, connection_id, documents}. A multi-party run's OTHER counterparties are named only here —connection_idabove names just the primary.documentsis that participant's own copy of every output document the run produced (empty before generation), each{output_key, name, document_id, document_status, requires_signature, requires_acceptance, position, action, acted_at}, ordered byposition— the step's 1-based place in the run's signing line,null(listed last) for a party that output's signer list does not name. Participants are ordered by their first position.answerscarry one ciphertext per answer per party, as held in your company's region — every copy sealed to your company among them, and those hold the whole answer map; another party's own copy may be absent. You decrypt thefor_user_id == <your company user>copies with your service private key. An answer the person linked from one of their verified fields also carries the verification that field held when they submitted —verified_hash,verified_salt,verified_at,verified_expires_at, plusverified_method,verified_provider,verification_id— with the same meaning as on a connection value: recomputesha256(verified_salt + decrypted_plaintext)and trust it only on a match. A typed or captured answer carries none. A FILE answer (a photo or document field) is not a wrapper but the plaintext reference{"_enc_file": "<file>"}— or{"_enc_file": "<file>", "_link": 1}for a file the person linked from their profile, whose submitted copy was frozen into the run — and reads as answered; download your company's copy withGET …/slots/{slug}/file.definitionis the pinned, immutable graph. Render the current node and evaluate its outgoing edges from this definition — never re-fetch the live flow (it may have changed).
How long a run's data is kept
A run's answers (every party's copy, with their files), its source files and its customer text values (tag_values) are deleted:
- 30 days after your account moves to a level without contract flows, for the runs of your services started before that date;
- 7 days after your account moves to the free level, together with the rest of the data your services hold for the people connected to them (what is deleted);
- when you delete the service.
A run still in progress when its data is deleted is cancelled first. Taking a level that includes the lost capability before the date cancels the deletion. The run itself, its participants, its output documents and their signatures are kept: a cleared run reads with an empty answers list, source_files {} and tag_values null. Store what you need from a run before then — including the verification_id of every linked verified answer, which is how you refer allme to that verification later.
POST /api/company-data/flow-runs/{runId}/answers
Submit your company's turn. You compute the routing locally, fill the current node, and post the new answers plus the next node.
Answerable field types. A flow step can use any type the platform carries — the requestable set (every type except an option-less choice type), plus the flow-only choice types select/multiselect, whose options the step itself supplies. There is no exclusion list. The set is DERIVED from the served registry rather than listed anywhere — see Field types & the typed value model — so a field typed country is answered as an ISO 3166-1 alpha-2 code because that is what its primitive says, and a step typed with a PARENT accepts a person's field of any type below it, legal_document and the ID subtypes included. The SDK type-checks each answer against the field type from the run's pinned definition.
The turn, step by step:
- Decrypt the run's existing answers (your service-key copies).
- Run the
FlowConditionEvaluatorover the current node's outgoing edges to pick the first matching edge → that gives you the next node. - Fill the current node's answer(s).
- Re-encrypt each new answer for every bound party — your company copy with the service key, each person copy with that person's public key (resolve the key via the connection's
share_code→GET /api/keys/{shareCode}). - POST the answers with the computed
next_node(orleaf: trueon a terminal node).
- Body:
{ "answers": [{"slug": "…", "values": [{"for_user_id": "<party user_id>", "value": "<{\"_enc\":1,…}>"}, …], "source_private": true?}], "next_node": "<key>"?, "leaf": true? }. Each answer needs one value per bound party, and eachvalueis the sealed wrapper serialized as a JSON string (as in the example below), never a JSON object. The SDKs send it that way. source_private(boolean, absent =false) — the value came from a private source. It is written on every party's copy of the slug and read back as the run'sprivate_slugs. The SDKs set it for you (Plugin fields).
POST /api/company-data/flow-runs/{runId}/answers
Authorization: Bearer ey...
Content-Type: application/json
{"answers": [
{"slug": "plan_tier",
"values": [
{"for_user_id": "019aaa…", "value": "{\"_enc\":1,\"k\":\"…\",\"iv\":\"…\",\"d\":\"…\"}"},
{"for_user_id": "019bbb…", "value": "{\"_enc\":1,\"k\":\"…\",\"iv\":\"…\",\"d\":\"…\"}"}
]}
],
"next_node": "ask_billing"}
| Status | error_key | Meaning |
|---|---|---|
400 | contact_data.encryption_required | A value is plaintext — every answer value must be a valid {"_enc":1,…} wrapper. |
400 | flows.answer_party_missing | An answer is missing a copy for one of the bound parties (one value per party is required). |
400 | flows.answer_invalid | An answer has no slug, or its source_private is present but not true or false. |
400 | flows.answer_party_invalid | A for_user_id is not a bound party of the run. |
400 | flows.next_node_invalid | next_node is not a successor of the current node in the pinned definition. |
403 | flows.not_your_turn | The run is awaiting a different party — not yours. |
409 | flows.run_not_awaiting | The run isn't in an awaiting-a-turn state (e.g. already completed or awaiting signature). |
POST /api/company-data/flow-runs/{runId}/answer-files
A binary field on your company's turn. Upload the file once per bound party, each copy the file's envelope JSON ({"file": "data:<mime>;base64,…", "original_name", "mime_type", "size"} for a document) sealed to that party's key, then submit {"_enc_file": "<file>"} (exactly that key) as each party's answer value.
- Body:
{"slug", "for_user_id", "value"}—sluga binary field of the current step,for_user_ida bound party,valuethe sealed wrapper string. - Returns:
201 {"file"}. The same checks as your turn's submit (your party on the clock), and the field's upload size cap (400 flows.answer_file_too_large).
GET /api/company-data/flow-runs/{runId}/slots/{slug}/file
Your company's own copy of a run's file answer — what was submitted in the run, sealed to your service key. For a file the person linked from their profile this is the frozen copy made at submit; GET /api/company-data/connections/{connectionId}/flow-links/{flowId}/{slug}/file keeps serving their CURRENT file.
GET /api/company-data/flow-runs/{runId}/source-files/{sourceKey}
Your company's own copy of one connection source, sealed to your service key — sourceKey is a key of the run's source_files, URL-encoded (it contains :). Returns the stored {"_enc":1,…} wrapper; its plaintext is the file's envelope JSON.
POST /api/company-data/flow-runs/{runId}/generate
Document-mode leaf only. Produce the leaf's output documents as contracts from the gathered answers. Because the platform is zero-knowledge, you hand it a one-time key (OTK) and the leaf's answer values encrypted under that key; the platform decrypts just for this fill, picks each output document's first matching rule, substitutes each {{slug}} tag in that rule's PDF template, re-encrypts each resulting document per party, and stores it as a contract.
- Body:
otk—base64(32 random bytes), a freshly generated one-time AES-256 key.values—base64( iv(12) || AES-256-GCM(otk, JSON({slug: plaintext_value, …})) || tag(16) ): the leaf's slug→plaintext map, AES-256-GCM-encrypted under the OTK.inputs—[{"source_key", "input"}]: one uploaded generation input per participant PDF source the run holds for your company at this leaf;[]when it holds none.
- Returns:
{ "documents": [{"output_key","party_key","document_id","position"}], "status": "awaiting_signature" }.documents[]carries one entry per produced (output document, participant) — every participant's own copy of every output — with its 1-basedpositionin the run's signing line (nullfor a party that output's signer list does not name). Idempotent — a second call returns the same set.
POST /api/company-data/flow-runs/{runId}/generate
Authorization: Bearer ey...
Content-Type: application/json
{"otk": "<base64 32 random bytes>",
"values": "<base64( iv(12) || AES-256-GCM(otk, JSON({slug: value,…})) || tag(16) )>",
"inputs": [{"source_key": "field:signed_offer", "input": "<from /generate/inputs>"}]}
| Status | error_key | Meaning |
|---|---|---|
400 | flows.generation_failed | No output document of the leaf had a matching rule, or a PDF couldn't be filled — usually a mangled template. Each {{slug}} tag must be a contiguous literal in the PDF (not split across text runs). The run stays at generating. |
400 | flows.generate_inputs_mismatch | The inputs' source keys are not exactly the sources the run holds for your company at this leaf, or an input is not one you uploaded for this run. |
400 | flows.source_pdf_invalid | A participant PDF the matched rule names is not a usable PDF. The run stays at generating; the way out is cancelling the run. |
409 | flows.not_generatable | The run isn't at a document-mode generatable leaf. |
404 | flows.run_not_found | No such run for your service. |
The generated documents then surface in each party's documents to sign along the run's signing line. Signing flips that copy to active and opens the next step of the line; the last signature completes the run. You observe every advance on the change feed: document_status_changed with action: signed (or accepted) per signature, and — when the next step's copy opens as a result — a second document_status_changed carrying its new ready_to_sign/offering status plus a flow_run_turn naming that party; flow_completed fires once every document of the run is terminal. A decline or cancellation on any document cancels the run and ends every open or waiting copy of every output document.
Download your company's own copy of each output document with GET /api/company-data/flow-runs/{runId}/documents/{outputKey}/file — outputKey is an entry's output_key from the generate response or from your participant's documents.
POST /api/company-data/flow-runs/{runId}/generate/inputs
Upload one held participant PDF for the next generate call. Fetch your own copy of the source (slots/{slug}/file for field:<slug>, source-files/{sourceKey} for conn:…), decrypt it with your service key to its envelope JSON, and encrypt that string with the SAME one-time key you send as otk — AES-256-GCM, a fresh 12-byte iv, base64(iv || ciphertext || tag), exactly the values layout.
- Body:
{"source_key", "value"}. Returns:201 {"input"}; send it in the generate call'sinputs. - Only while the run is
generating, with the same caller checks as its generate route, within the upload budget (400 documents.too_large). The server decrypts each input with the call's one-time key in memory and discards it after the call. - Held means: for
field:<slug>, your company's own answer to that field is a file reference; forconn:<party>:<request_slug>, the run'ssource_filesnames it. The SDKs compute the held set and upload the inputs insidegenerateFlowDocument.
Output documents — what a document leaf produces
A document leaf (authored in the portal's flow builder) declares one or more named output documents — for example "Contract" and "Addendum" — and a language party. Each output document has a stable key (the output_key you see on the run and the generate response), a name (the generated document's name) and an ordered list of rules. Each rule names a PDF — a company template or a participant's PDF — an optional condition over the answers, the languages it applies to (All languages, or a list of language codes) and an optional ordered signers list.
- At generation, for each output document in order, its rules are tested in list order and the first match wins: a rule matches when its condition holds and its languages are All languages or contain the language party's language (the bound party's own profile language).
- An output document with no matching rule produces no document. When no output document produces one, generation fails with
flows.generation_failedand the run stays atgenerating. - Every participant receives its own copy of every produced output document, named with the output document's name.
Where a rule's PDF comes from
A rule carries exactly one PDF source:
| Rule member | The PDF | Source key |
|---|---|---|
asset_key | a company template uploaded in the portal | — |
source_field: "<slug>" | the run's answer to a flow field of type pdf_document (or a type below it), on any step | field:<slug> |
source_connection: {"party", "request_slug"} | what the customer bound to party shared for your service's pdf_document request field request_slug, copied at run start | conn:<party>:<request_slug> |
- A rule whose source the run does not hold does not match, and the next rule is tried — an unanswered field falls through to your template rule.
- A participant PDF is filled like a template: every
{{tag}}it carries whose key is answerable at the leaf is filled, every other tag prints empty. pdf_documentis a field type belowdocumentwhose picked file must be a PDF (application/pdf, starting with%PDF-); the person apps and the portal offer PDF files only and refuse any other. A request fordocumentaccepts apdf_documentfield.- Publish refuses a rule with none or more than one source (
pdf.source_invalid), asource_fieldthat is not apdf_documentflow field (pdf.source_field_invalid), and asource_connectionwhose party is not a typed person or company party, or whose request field is not apdf_documentrequest field of the service with audiencebothor that party's type (pdf.source_connection_invalid). - Frozen copies of linked files. When a person answers a file field by linking a profile file, every bound party gets a frozen copy of the submitted file (when it fits the upload budget), so a
source_fieldrule can use it; the link itself stays live. A linked file too large to copy stays a live link only and is not held.
Connection sources — copied at run start
For every source_connection a rule of the latest published version names whose request slot the bound customer has answered (not expired, still type pdf_document), the starter makes one copy per distinct bound user — your company's own copy sealed to your service key, a person's to their public key, a company customer's to its account key — stages each through POST …/flows/{flowId}/run-files, naming the customer whose file it is, and names them in the trigger's source_files. Read the customer's file from its slot file route (any of its three response shapes) and seal its envelope JSON — for raw bytes, {"file": "data:<Content-Type>;base64,…", "original_name", "mime_type", "size"}. A source you do not stage comes back in the refused start's missing list. The copy is a snapshot: a later change on the connection does not reach the run.
Upload budget. run-files and generate/inputs share one limit on the sealed value string: DOCUMENT_MAX_MB × 16/9 + 64 KiB (the double-base64 sealing of a maximum-size document).
The signing line — one signer at a time, across every output document
A rule may declare an ORDERED signers list: [{"party": "customer", "action": "sign"}, {"party": "owner", "action": "sign"}]. A rule with no signers list means every participant signs, one at a time, in the flow's party order, action sign. A party an output's list does not name receives that output's copy already active — it owes nothing.
All signatures of a run form ONE signing line, built by place, then by output order: every output's first signer, in output order, then every output's second signer, and so on. Exactly one step is open at a time — its copy is ready_to_sign (or offering for an accept); every later step's copy stays waiting (readable, not yet signable) until the step before it has acted. Each output's own order always holds. For example, Contract [customer, owner] and Addendum [customer, owner] give the line customer·Contract, customer·Addendum, owner·Contract, owner·Addendum. Each output document is sealed when its own last signer acts.
The run read's participants[].documents[] (see GET a run) carries each step's place in the line: position (1-based, null for an unlisted party), action (signed|accepted|null until it acts) and acted_at. A run-participant document carries the WHOLE run's line as run_signatures — one entry per (output document, participant), in line order, each {output_key, name, party_key, document_id, position, status, action, acted_at}, the same on every document of the run — signature metadata only, never an executed multi-signature artifact.
PUT /api/company-data/documents/{id} refuses a status write on such a document while it is waiting, ready_to_sign or offering — 409 documents.run_managed. That status advances only through generation, the signing line itself, sign/accept, or a run cancel/decline./api/company-connections/{id}/flow-runs* — you do not call those. As a developer you integrate only from the company-data side documented here; the person's turns reach you as state changes on the run (and as flow_run_turn / flow_completed events).SDK run methods
The SDKs wrap every run endpoint and port the FlowConditionEvaluator + an encrypt_for_public_key helper, so a turn becomes "decrypt → route → fill → encrypt per party → submit" without hand-rolling the crypto or the routing. Each new answer is encrypted per party automatically: persons' public keys resolve via the connection's share code → GET /api/keys, the company via its service key.
identity()— the client's own{company_user_id, service_id}fromGET /api/company-data/whoami. The company party of a run'sbindingsmust bind tocompany_user_id(the person party's user_id comes from the connection).publishedFlow(flow_id)— the flow's latest published version (published).triggerFlowRun(flow_id, connection_id, bindings, sourceFiles = [])— start a run (trigger): reads the published version, compiles the run's text values, sendssource_fileswhen given, and retries once onflows.version_changedor a changed customer key. A refused start'smissing/unexpectedride the SDK's API error details.stageRunFile(flow_id, sourceUserId, sealedValue)— stage one sealed connection-source copy of the file the customersourceUserIdshared → itsfile(run-files).uploadAnswerFile(run_id, slug, forUserId, sealedValue)— upload one bound party's copy of a binary answer on your turn → itsfile(answer-files).flowRunSourceFile(run_id, sourceKey)— your company's own sealed copy of a connection source (source-files). The run model carriessource_files.flowRuns(status?)— list runs, optionally by status (list).flowRun(run_id)— fetch one run in full (get).flowRunAnswers(run)— a completed run's DECRYPTED answers:answersis the{slug: plaintext}map,unreadablethe slugs whose answer your service key could not open (empty when every answer opened); an unreadable answer is left out ofanswersand never fails the call. The public accessor for a finished run's answers (whichprocessFlowRunreturns untouched). Go has noFlowRun|Stringunion, so it takes a fetchedFlowRun.submitFlowAnswers(run, fill)— submit your turn (answers) from a fetched run and the node's{slug: plaintext}fill; the SDK type-checks each answer against its field type (from the run's pinned definition) and raisesValidationErroron a bad value, evaluates the next node locally, then encrypts each answer for every party.generateFlowDocument(run)— generate the leaf's output documents on a document leaf (generate); the SDK decrypts the run's answers from your service-key copies, mints the OTK and packs the whole answer map for you, and first uploads every participant PDF the run holds for you at the leaf as a generation input. Returns{documents, status}— one{output_key, party_key, document_id, position}per produced (output document, participant).flowRunDocument(run_id, output_key)— download the company's OWN copy of one output document as plaintext file bytes; GETs/flow-runs/{run_id}/documents/{output_key}/file, whose company-party copy is encrypted to your service key, and the SDK decrypts it → the bytes (404flows.no_documentwhen that output was not produced). This is what the honest completion step downloads, once per output (fill → complete →flowRunAnswers→flowRunDocument).documentFile(document_id)— download a document's file BYTES: a broadcast (plaintext) document's bytes are returned as-is, but a per-person document is encrypted to the recipient's key and is NOT company-readable, so it fails clearly (documents.recipient_encrypted). For a generated flow document's own copy useflowRunDocumentabove (that copy is service-key-encrypted).processFlowRun(run_id, fillNode)— the convenience chain: on a run that is your turn,fillNode(node, answers)returns the node's fill, thensubmitFlowAnswers, and on a document leafgenerateFlowDocumentin one call.pluginPass(run_id)/pluginOptions(…)/pluginOutputs(…)/checkFlowValue(…)— answer a plugin field on your turn and apply a field's minimum and maximum before you submit.
Method names follow each language's conventions — Python snake_case, TypeScript/Java camelCase, C# PascalCase, Go exported, PHP camelCase:
# Drive a run that is now the company's turn
client.process_flow_run(run_id, lambda node, answers: {"plan_tier": "pro"})
# evaluate → submit; and generate on a document leaf
# or step by step:
run = client.trigger_flow_run(flow_id, connection_id="019…",
bindings={"company": "019aaa…", "customer": "019bbb…"})
for run in client.flow_runs(status="awaiting_company"):
run = client.submit_flow_answers(run, {"plan_tier": "pro"})
if run.status == "generating": # the submit landed on a document leaf
client.generate_flow_document(run)// Drive a run that is now the company's turn
await client.processFlowRun(runId, (node, answers) => ({ plan_tier: 'pro' }));
// evaluate → submit; and generate on a document leaf
// or step by step:
const started = await client.triggerFlowRun(flowId,
{ connectionId: '019…', bindings: { company: '019aaa…', customer: '019bbb…' } });
for (let run of await client.flowRuns({ status: 'awaiting_company' })) {
run = await client.submitFlowAnswers(run, { plan_tier: 'pro' });
if (run.status === 'generating') { // the submit landed on a document leaf
await client.generateFlowDocument(run);
}
}// Drive a run that is now the company's turn
run, err := client.ProcessFlowRun(ctx, runID,
func(node, answers map[string]any) map[string]any { return map[string]any{"plan_tier": "pro"} }, nil)
// evaluate → submit; and generate on a document leaf
// or step by step:
started, err := client.TriggerFlowRun(ctx, flowID, "019…",
map[string]string{"company": "019aaa…", "customer": "019bbb…"})
runs, err := client.FlowRuns(ctx, "awaiting_company")
for _, r := range runs {
r, err = client.SubmitFlowAnswers(ctx, r, map[string]any{"plan_tier": "pro"}, nil)
if r.Status == "generating" { // the submit landed on a document leaf
_, err = client.GenerateFlowDocument(ctx, r)
}
}// Drive a run that is now the company's turn
await client.ProcessFlowRunAsync(runId, (node, answers) =>
new Dictionary<string, object?> { ["plan_tier"] = "pro" });
// evaluate → submit; and generate on a document leaf
// or step by step:
var started = await client.TriggerFlowRunAsync(flowId, "019…",
new Dictionary<string, string> { ["company"] = "019aaa…", ["customer"] = "019bbb…" });
foreach (var queued in await client.FlowRunsAsync("awaiting_company"))
{
var run = await client.SubmitFlowAnswersAsync(queued,
new Dictionary<string, object?> { ["plan_tier"] = "pro" });
if (run.Status == "generating") // the submit landed on a document leaf
await client.GenerateFlowDocumentAsync(run);
}// Drive a run that is now the company's turn
client.processFlowRun(runId, (node, answers) -> Map.of("plan_tier", "pro"));
// evaluate → submit; and generate on a document leaf
// or step by step:
FlowRun started = client.triggerFlowRun(flowId, "019…",
Map.of("company", "019aaa…", "customer", "019bbb…"));
for (FlowRun queued : client.flowRuns("awaiting_company")) {
FlowRun run = client.submitFlowAnswers(queued, Map.of("plan_tier", "pro"));
if ("generating".equals(run.status())) { // the submit landed on a document leaf
client.generateFlowDocument(run);
}
}// Drive a run that is now the company's turn
$client->processFlowRun($runId, fn (array $node, array $answers): array => ['plan_tier' => 'pro']);
// evaluate → submit; and generate on a document leaf
// or step by step:
$started = $client->triggerFlowRun($flowId, '019…',
['company' => '019aaa…', 'customer' => '019bbb…']);
foreach ($client->flowRuns('awaiting_company') as $run) {
$run = $client->submitFlowAnswers($run, ['plan_tier' => 'pro']);
if ($run->status === 'generating') { // the submit landed on a document leaf
$client->generateFlowDocument($run);
}
}The customer role generates too
The party that answers a run's last step generates the contract. When your company is a
customer of another company's service — bound to a party of that company's flow through an
account client (acct_*) — and your answer completes a
document-mode leaf, the run parks at generating until you generate:
POST /api/company-connections/{connectionId}/flow-runs/{runId}/generate, with the same
{otk, values} body and the same response as above. The answer map comes
from your OWN copy of the run's answers, decrypted with your account key: every party's answers are sealed to
every bound party, so that copy holds the whole run and no service key is involved. Held participant PDFs
are uploaded first through POST /api/company-connections/{connectionId}/flow-runs/{runId}/generate/inputs,
each read from your own copy through GET …/flow-runs/{runId}/answer-files/{file} (for a
connection source, file is the run's source_files[source_key]) and decrypted with
your account key. The SDKs' CustomerClient does it in one call, after you submit the leaf:
run = customer.flow_run(connection_id, run_id) # re-read after your leaf submit
customer.generate_flow_document(connection_id, run)const run = await customer.flowRun(connectionId, runId); // re-read after your leaf submit
await customer.generateFlowDocument(connectionId, run);run, err := customer.FlowRun(connectionID, runID) // re-read after your leaf submit
_, err = customer.GenerateFlowDocument(connectionID, run)var run = await customer.FlowRunAsync(connectionId, runId); // re-read after your leaf submit
await customer.GenerateFlowDocumentAsync(connectionId, run);FlowRun run = customer.flowRun(connectionId, runId); // re-read after your leaf submit
customer.generateFlowDocument(connectionId, run);$run = $customer->flowRun($connectionId, $runId); // re-read after your leaf submit
$customer->generateFlowDocument($connectionId, $run);The call refuses a run whose current step is not bound to your company (the SDKs' ConfigError),
and it is idempotent: a repeat answers the same document set.
An owner automation on an account client (capability
service_management) that answers its own flow's leaf through
POST /api/services/{id}/flows/{flowId}/runs/{runId}/answers generates on the same family:
POST /api/services/{id}/flows/{flowId}/runs/{runId}/generate, same body and response as
above. The owner's copy of the answers is sealed to your service key, so the
values map is built from that copy with the service private key you hold. The same family
carries the rest of this page's run surface: POST …/runs/{runId}/answer-files,
POST …/runs/{runId}/generate/inputs, GET …/runs/{runId}/source-files/{sourceKey}
and POST /api/services/{id}/flows/{flowId}/run-files.
Text elements and value tags
A step's text element is plain text with a small markup — never HTML. HTML tags are removed; [b]…[/b] and [i]…[/i] are bold and italic; [a href=X]…[/a] is a link whose address must start with https:, mailto: or tel:; \[, \], \{ and \\ are the literal characters. Value tags fill in the text and in link addresses:
{{slug}}— an answer of the run (or a computed constant), shown as the participant fills the flow;—until answered.{{party.field}}for a customer party — what the bound customer shared for your request fieldfield. The starter compiles these once, at start.{{party.field}}for your owner party — your company's own profile value for contact fieldfield, filled by the platform at start.
Compiling the customer's values. Read the flow's published version and collect the customer-party tags of its text elements. Read the connection detail, open each named slot's value with your service private key, and build, for your company and for the customer: ONE wrapper ({"_enc":1,…}, as a JSON string) of {"party.field": {"v": value, "t": field_type}} holding every value the connection's values_private marks false, and each other value sealed on its own. The company's copy goes to your service public key, the customer's to the customer's key (GET /api/keys/{shareCode}). Send them as:
"tag_values": {
"company": {"recipient_pubkey_sha256": "<hex>", "public": "<wrapper>", "public_tags": ["customer.name"], "private": {"customer.iban": "<wrapper>"}},
"<user_id>": {"recipient_pubkey_sha256": "<hex>", "public": "<wrapper>", "public_tags": ["customer.name"], "private": {"customer.iban": "<wrapper>"}}
}
recipient_pubkey_sha256 is the lowercase hex SHA-256 of the public key you sealed to (its DER bytes). A value that is absent or does not open is left out. A non-private value is shown to every participant of the run; a private one only to its own party and to you. The SDKs' triggerFlowRun does all of this.
Change-feed & webhook events
Two run-level events ride the same drain-on-fetch change feed and webhooks as every other change — handle them in the same consumer. Both are slug-free, run-level events (no person source field is ever exposed):
| Event | When | Carries |
|---|---|---|
flow_run_turn | A run became a party's turn | flow_run_id + awaiting_party (the party now owed a turn) + person + ts. This is how your service learns a run needs you — equivalently poll flow-runs?status=awaiting_company. |
flow_completed | A run reached its terminal state (both output modes) | flow_run_id + person + ts. For data_only it is the terminal signal and the cue to read the now-shared answers (connection detail / change feed). For document it fires after every document of the run is signed (or accepted). |
Payload shape (slug-free, run-level):
{
"id": "chg-…", // pull feed: the change-row id (dedup key). WEBHOOK: not a dedup key — see webhooks.html
"event": "flow_run_turn", // or "flow_completed"
"person_user_id": "…",
"share_code": "ABC123", // the person's profile share code
"flow_run_id": "run-…",
"awaiting_party": "company", // flow_run_turn only — the party now owed a turn
"at": "2026-06-22T13:00:00Z"
}flow_run_id (and on flow_run_turn the awaiting_party), never a slug or a value. The person's source field_id is never exposed. Dedup on id like any other change.Plugin fields
A company adds a plugin in the portal (Configuration › Plugins) — its own, anybody's, or one the platform offers — and places one of the plugin's field types on a step as a plugin element. The plugin describes the field in blocks (searchable picks and typed values), the inputs it needs and the outputs it computes; how a plugin itself is built is on Building plugins. In the run's pinned definition the element reads:
{ "kind": "plugin", "slug": "cao", "label": "CAO", "required": true, "show_if": null,
"plugin": {
"plugin_id": "019…",
"field_type": "cao",
"snapshot": { "plugin_name": "Flex", "host": "integrations.allme.fyi", "label": "CAO",
"blocks": [{"key":"cao","kind":"search_select","label":"CAO"},
{"key":"scale","kind":"search_select","label":"Scale","depends_on":"cao"},
{"key":"step","kind":"search_select","label":"Step","depends_on":"scale"}],
"inputs": [{"key":"age_at_start","type":"number","label":"Age at start","required":true},
{"key":"start_date","type":"date","label":"Start date","required":false}],
"outputs": [{"key":"min_wage","type":"number","label":"Minimum wage"},
{"key":"agreement_wage","type":"number","label":"Agreement wage"}] },
"inputs": { "age_at_start": "age" } } }
- Element kinds are
field,text,imageandplugin; any other kind is refused at publish (element.kind_invalid). snapshotis the plugin's description of the field type, copied from its current manifest when the flow is saved or published. A run renders and reads back from it and never needs the plugin for that.inputswires each plugin input to a flow key — a field slug, a plugin answer key or a constant — answered on an earlier step or on the element's own step, of a matching type (number← number,date← date,boolean← boolean,text← any). An input left unwired is absent; an unwired required input is refused at publish.requiredis the element's own.- The answer is one self-describing JSON string — the plugin's name, the field type, the blocks in declared order (a pick's
idand option label, or a typed value) and the outputs with their labels and types (shape). It is submitted like any other answer: one wrapper per bound party. An answer without anoutputsarray is unfinished and reads as unanswered.
Reading a plugin answer: dotted refs
A plugin element cao is readable in conditions, constants, defaults, bounds, plugin inputs and PDF tags under these keys:
| Key | Value | Type |
|---|---|---|
cao | The blocks' values joined by / (e.g. Horeca Fictief / Schaal C / Trede 4) | text |
cao.<block> | The block's value: a pick's option label, or the typed value | text for search_select and text; else the block's kind (number, date) |
cao.<block>.id | The picked option's id — search_select blocks only | text |
cao.<output> | The output's value; a null output adds no key | the output's declared type |
Every runtime — the apps, the portal, the PDF filler and all six SDKs — builds these keys with the same pure function, expandPluginAnswers(answers, pluginSlugs), before computeConstants, and routes over the expanded map: edges, show_if and available_if see cao.min_wage exactly as they see a field slug. A PDF tag may carry up to two dots — {{cao}}, {{cao.scale}}, {{cao.scale.id}}, {{cao.min_wage}} — and a number prints as the evaluator's stringification.
Answering a plugin field on your turn
A plugin is never called by the allme API. Your client seals each question to the plugin's public key and posts it, with a short-lived pass, to the platform's forwarder; the plugin answers sealed to a reply key you made. The forwarder carries both directions unopened.
- Get a pass for the run:
POST /api/company-data/flow-runs/{runId}/plugin-pass— your service client, for the owning company's party.POST /api/company-connections/{connectionId}/flow-runs/{runId}/plugin-pass— an account client (acct_*), for a company that is a customer party of another company's flow.
{ "pass": "<JWT>", "forwarder_url": "https://integrations.allme.fyi/plugins", "plugins": [{"id": "019…", "public_key": "<base64 SPKI>"}], "specs": {"cao": { "plugin_id": "019…", "field_type": "cao", "snapshot": {…}, "inputs": {…} }} }specsis keyed by element slug. A plugin whose description could not be fetched is listed withpublic_key: null: its field is not responding. A step with no plugin element answers200with an emptypluginslist.Status error_keyMeaning 404flows.run_not_foundNo such run for your client. 409flows.run_not_awaitingThe run is not awaiting input. 403flows.not_your_turnThe current node belongs to another party. 422flows.definition_invalidThe current node is missing from the pinned definition. 409account.counterparty_deletedThe person has deleted their allme account; the run is read-only. - Seal the question —
{field_type, op, block, query, picks, values, inputs, reply_key}(the plugin protocol) — to the plugin'spublic_key, with the public half of a fresh RSA-2048 reply key asreply_key. Inputs are read from your live answer map and converted to their declared types. - Post it to
{forwarder_url}/callas{"pass": "…", "plugin_id": "…", "request": "<wrapper string>"}, over a plain HTTP client that carries no allme credential — the pass rides the body. The answer is200 {"reply": "<wrapper string>"}; open it with the reply key.
| Forwarder status | error_key | What to do |
|---|---|---|
401 | plugin.pass_invalid | Get a new pass once. |
403 | plugin.not_in_pass | Get a new pass once. |
409 | plugin.key_changed | The plugin's key changed; the body carries the new public_key. Reseal once with it. |
400 | plugin.request_invalid | The call carried no sealed request. |
404 | plugin.not_found | The plugin does not exist. |
429 | plugin.busy / plugin.rate_limited | The plugin's in-flight cap, or your per-plugin window (60 calls a minute), is full. |
502 | plugin.not_responding | The plugin did not answer in time, answered an error, or has no stored description. |
503 | plugin.unavailable | The forwarder cannot answer right now. |
private_slugs, when it is a constant that reads a private source, or when it is a value of the current step derived from one — a field whose default reads a private source. A plugin answer is never private, whatever inputs produced it. A run read with no private_slugs list counts every other party's value as private. A required input that is private, unanswered, unwired or not convertible means the field cannot be answered yet; an optional one is left out of the call. Your own answers that derive from a private source are submitted with source_private: true.In the SDKs
The service Client does all of this in three calls — pluginPass(runId), pluginOptions(runId, slug, block, query, picks, values, draft) and pluginOutputs(runId, slug, picks, values, draft) — and the CustomerClient has the same three with a leading connectionId. draft is the current step's answers you have not submitted yet (slug → plaintext): the SDK reads inputs from ONE live answer map — the run's answers it can read, overlaid with draft, plugin answers expanded, constants computed.
pluginOptionsanswers the block's options (id,label) andmore(the list was cut — narrow the query).pluginOutputsanswers the outputs, or aPluginPicksInvalidresult when the picks no longer fit the inputs or each other — clear them and pick again. After an input changes, call it again before you submit.- A required input that is unwired, unanswered, another party's private value or not convertible raises
PluginInputUnavailablenaming the input, its source and the reason (unwired|unanswered|other_party_private|not_convertible). - The forwarder is reached over a plain transport that carries no allme credential and never rewrites the URL; the SDK makes a fresh reply key per call, reseals once on
plugin.key_changed, gets a new pass once on 401/403, and raises any other refusal asApiErrorwith the forwarder'serror_key. submitFlowAnswersmarkssource_private: trueon every answer whose field default reads a private source (never on a plugin answer), and refuses a value outside its field's minimum or maximum.
from allus_company_data import PluginPicksInvalid
draft = {"age": "19"} # this step's answers so far
opts = client.plugin_options(run_id, "cao", "cao", query="hor", draft=draft)
picks = {"cao": opts.options[0]["id"], "scale": "c", "step": "4"}
out = client.plugin_outputs(run_id, "cao", picks=picks, draft=draft)
if isinstance(out, PluginPicksInvalid):
... # clear the picks and pick again
print(out.outputs["min_wage"])import { PluginPicksInvalid } from '@allus-fyi/company-data';
const draft = { age: '19' }; // this step's answers so far
const opts = await client.pluginOptions(runId, 'cao', 'cao', 'hor', {}, {}, draft);
const picks = { cao: opts.options[0].id, scale: 'c', step: '4' };
const out = await client.pluginOutputs(runId, 'cao', picks, {}, draft);
if (out instanceof PluginPicksInvalid) {
// clear the picks and pick again
} else {
console.log(out.outputs.min_wage);
}draft := map[string]any{"age": "19"} // this step's answers so far
opts, err := client.PluginOptions(ctx, runID, "cao", "cao", "hor", nil, nil, draft)
picks := map[string]string{"cao": opts.Options[0].ID, "scale": "c", "step": "4"}
out, err := client.PluginOutputs(ctx, runID, "cao", picks, nil, draft)
switch r := out.(type) {
case *companydata.PluginPicksInvalid:
// clear the picks and pick again
case *companydata.PluginOutputs:
fmt.Println(r.Outputs["min_wage"])
}var draft = new Dictionary<string, object?> { ["age"] = "19" }; // this step's answers so far
var opts = await client.PluginOptionsAsync(runId, "cao", "cao", "hor", draft: draft);
var picks = new Dictionary<string, string> { ["cao"] = opts.Options[0].Id, ["scale"] = "c", ["step"] = "4" };
var result = await client.PluginOutputsAsync(runId, "cao", picks, draft: draft);
if (result is PluginOutputs outputs)
Console.WriteLine(outputs.Outputs["min_wage"]);
else
{ /* PluginPicksInvalid: clear the picks and pick again */ }Map<String, Object> draft = Map.of("age", "19"); // this step's answers so far
PluginOptionsResult opts = client.pluginOptions(runId, "cao", "cao", "hor", Map.of(), Map.of(), draft);
Map<String, String> picks = Map.of("cao", opts.options().get(0).id(), "scale", "c", "step", "4");
PluginOutputsResult result = client.pluginOutputs(runId, "cao", picks, Map.of(), draft);
if (result instanceof PluginOutputs outputs) {
System.out.println(outputs.outputs().get("min_wage"));
} else {
// PluginPicksInvalid: clear the picks and pick again
}use Allus\CompanyData\Model\PluginPicksInvalid;
$draft = ['age' => '19']; // this step's answers so far
$opts = $client->pluginOptions($runId, 'cao', 'cao', 'hor', [], [], $draft);
$picks = ['cao' => $opts->options[0]['id'], 'scale' => 'c', 'step' => '4'];
$out = $client->pluginOutputs($runId, 'cao', $picks, [], $draft);
if ($out instanceof PluginPicksInvalid) {
// clear the picks and pick again
} else {
echo $out->outputs['min_wage'];
}The answer you then submit for cao is the plugin answer's JSON string — blocks in declared order with their labels from the spec's snapshot, then the outputs.
Computed constants
A flow may declare constants — hidden, author-only values computed from the run's answers (and its reference_date). A constant is not an answer and is author-only by default — never filled by, or sent to, a participant. (A step may optionally show a constant's computed value read-only to the person via shown_on; see Showing a constant on a step.) It exists to (1) drive branching — a condition (edge, element show_if, option available_if, or leaf PDF selection) may compare against a constant key exactly as it does a field slug — and (2) be printed into the generated output. Constants live top-level in the flow graph as constants: [] and are pinned into each published version's definition, so an in-flight run's constants never change under it.
Every runtime (web, iOS, Android, the PHP API, and all six SDKs) computes them with the same pure function: computeConstants(constants, answers, reference_date) returns a new map = the answers plus one entry per constant, evaluated in dependency (topological) order. A reference to an operand not yet resolved yields null, and null propagates (any null operand makes the result null). Conditions are then evaluated as evaluate(condition, computeConstants(constants, answers, reference_date)) — the existing condition evaluator is unchanged; it simply sees the constant keys alongside the field slugs. On a flow with plugin elements the answers are first expanded (expandPluginAnswers), so a constant can read cao.min_wage like any field slug. All ports are pinned by a shared 62-case parity vector.
Constant shape
Each constant is { key, label, result_type, expr, shown_on }. shown_on is an array of node keys the constant is displayed read-only on ([] = internal-only, the default; see Showing a constant on a step). key matches ^[a-z][a-z0-9_]*$ and is unique across all field slugs AND all other constant keys; result_type is one of number, text, date, boolean. Dates are ISO YYYY-MM-DD strings throughout. expr is a tagged AST:
type | Shape | Result |
|---|---|---|
lit | {type:"lit", value_type, value} | a constant literal |
ref | {type:"ref", key} | a field slug, a dotted plugin key or another constant key (unresolved → null) |
today | {type:"today"} | the run's reference_date (never the device clock) |
if | {type:"if", cases:[{when:<Condition>, then:<Expr>}…], else:<Expr>} | first when that holds → its then, else else (else is required). when reuses the AND/OR/NOT condition tree of {field, op, value} leaves; leaves may reference constant keys. |
concat | {type:"concat", parts:[<Expr>…], sep} | text — string-coerce each part (null→""), join with sep (default "") |
datediff | {type:"datediff", unit:"days|weeks|months|years", from, to} | number — see date & number semantics |
math | {type:"math", op, args:[<Expr>…]} | number — op in add sub mul div mod round floor ceil abs neg max min |
Date & number semantics
datediffparses both operands as UTC-midnight calendar dates (a non-date operand →null); the sign followsto − from.days= whole days between them;weeks=trunc(days/7)toward zero;months=(to.y−from.y)*12 + (to.m−from.m), minus 1 ifto.day < from.day;years=to.y−from.y, minus 1 if(to.m, to.day) < (from.m, from.day)(standard age).mathcoerces args with the evaluator's numeric rule: numeric strings coerce; booleans are not numeric (→null). Anynullarg →null.add/mulare variadic;sub=args[0]−args[1];div/modby zero →null; a non-finite argument or result (overflow) →null;roundis round-half-away-from-zero;neg abs round floor ceilare unary.max/minare variadic (one argument or more) and are the exception to thenullrule: they take the largest / smallest of the arguments that coerce to a finite number and skip the rest (null, booleans, non-numeric strings); with no numeric argument the result isnull. Somax(cao.min_wage, cao.agreement_wage)is the agreement wage when there is no minimum wage.
today is the run's reference_date. It is stamped once (UTC) when the run is triggered, returned on the run payload, and fed into computeConstants by every runtime and by document generation — so a date constant is fully deterministic and never drifts between devices or over the run's life.Where constants show up for you
- In the generated document (
documentmode). The PDF filler runscomputeConstants(with the one-time key you supply at generate) and fills{{const_key}}template tags exactly like{{slug}}tags. A tag naming an unknown slug or constant key is flagged at author time (pdf.unknown_tag). - For
data_onlyflows, via the SDK. There is no new server output field — the rawcompany_dataAPI never materialises constants. Instead the SDK carries the samecomputeConstantsprimitive and already has the run's pinneddefinition, decryptedanswers, andreference_date, so it exposes a thin resolved-constants helper —(constants, answers, reference_date)→ a map of each constant key to its computed value (constants only, answers not folded in). See the SDKs page.
Showing a constant on a step
By default a constant is internal-only (shown_on: []). An author may list one or more node keys in a constant's shown_on; on each of those steps the person sees the constant's live-computed value rendered read-only — the constant's label (fallback: its key) above a greyed, non-editable value box — after that step's inputs. The value recomputes on every keystroke, and shows the translated Pending… placeholder (key flow_run_computed_pending) while it is still null. The displayed value is a plain string: text/number as-is, dates as YYYY-MM-DD, booleans as true/false; the value is not localized.
This is display-only: the shown value is never an answer, never part of a turn submission, never encrypted or stored, and has no effect on document generation ({{const_key}} printing is the separate mechanism above). Each shown_on entry must be an existing node key at which the constant is available, and a constant referenced only by shown_on still counts as used. shown_on rides inside the run's pinned definition.constants, so no API, run-payload, or storage field changes.
shown_on, and then only as a read-only render of a value derived from data both sides already have — never a new answer and never a channel for hidden values.Availability
A constant is usable at a given use site (an edge, a show_if/available_if, a leaf PDF, another constant) iff every field and constant it transitively references is available there. The portal builder computes this by a fixed-point over the existing field-availability set (it keeps adding any constant whose direct references are all already available), so it only offers a constant as an operand where it can actually resolve. This is pure static reference analysis — no evaluation — and the publish-time validator enforces it (constant.ref_not_answerable).
Validation (publish-time)
The server is the source of truth — publishing a flow blocks on any error. The constant checks:
error_key | Severity | Rule |
|---|---|---|
constant.key_invalid | error | key must match ^[a-z][a-z0-9_]*$ |
constant.key_duplicate | error | key must be unique across all field slugs and all other constant keys |
constant.ref_unknown | error | every ref.key targets an existing field slug or constant key |
constant.cycle | error | no cyclic references among constants |
constant.ref_not_answerable | error | at each use site, a referenced constant must be available there |
constant.shown_on_unknown_node | error | each shown_on entry must be an existing node key |
constant.shown_on_not_computable | error | the constant must be available (all transitive field/constant refs resolvable, including the node's own fields) at each node in its shown_on |
constant.type_mismatch | error | the inferred expression type must equal the declared result_type; datediff/math operands must be date/number-typed; if branches share a type; concat→text; math op arg-counts valid |
constant.expr_too_deep | error | an expression may nest at most 64 levels |
constant.unused | warning | defined but referenced by no condition or PDF tag |
pdf.unknown_tag | warning | (extended) a {{…}} template tag must name an available field slug, dotted plugin key or constant key — a .id tag on a block that is not a search_select, or on an output, is unknown |
Plugin elements, defaults and bounds add these checks, reported the same way:
error_key | Severity | Rule |
|---|---|---|
element.kind_invalid | error | an element's kind is field, text, image or plugin |
flow_plugin.unknown | error | the element's plugin is missing, or is another company's |
flow_plugin.field_type_unknown | error | the plugin's current description has no such field type, or no description was fetched |
flow_plugin.input_unwired | error | a required plugin input is not wired |
flow_plugin.input_not_answerable | error | an input is wired to a key not answered before or on the element's node |
flow_plugin.input_type_mismatch | error | an input's source does not match its declared type |
field.default_not_allowed | error | a default, minimum or maximum on a field where none is allowed (below) |
field.default_type_mismatch | error | the default's inferred type is not the field's |
field.bound_type_mismatch | error | a minimum or maximum on a field that is not a number or date field, or of another type |
field.ref_not_answerable | error | a default or bound references a key not answered at the field's node |
field.expr_too_deep | error | a default or bound nests more than 64 levels |
dependency.cycle | error | plugin inputs, defaults, bounds and constants form a cycle (constant.cycle stays for a cycle among constants only) |
A constant used only by a default, a bound or a plugin input counts as used.
Defaults, minimums and maximums
A field element may carry default, min and max, each an expression in the constants language over the keys answered at its node — field slugs, dotted plugin keys and constants:
{ "kind": "field", "slug": "wage", "label": "Hourly wage", "field_type": "decimal", "required": true,
"default": {"type": "math", "op": "max", "args": [{"type": "ref", "key": "cao.min_wage"},
{"type": "ref", "key": "cao.agreement_wage"}]},
"min": {"type": "ref", "key": "cao.min_wage"} }
- Where they are allowed. Not on a binary field, a choice field (
select/multiselect), or a field that must be linked (connected orverified). A default needs a field whose type istextor whose value is a number or a date, of the same type as the expression; a minimum or maximum needs a number or date field. - A default fills the field while it is empty, has no drafted or saved value and no selected link, and follows the default while the value still equals what the default last wrote; any other value is the person's. A number written into a
decimalfield is rounded to two decimals, half away from zero. - A minimum or maximum refuses a value below or above it — typed or linked — before the step can continue. Dates compare as dates. A bound that evaluates to
nullis no bound. - They are client-side gates, like
required: the answer route does not re-check them. The SDKs apply them for you:submitFlowAnswersrefuses a value outside its field's bounds with the SDK'sValidationErrornaming the bound (bound:min|max, andboundValue) before anything is encrypted, andcheckFlowValue(run, slug, value, draft)applies the same check on its own.
Next
- Contracts — the signable document a
document-mode run produces. - SDKs — the run methods, the ported evaluator, and the changes pump that delivers
flow_run_turn/flow_completed. - Webhooks — receive the run events as pushes instead of polling.
- Building plugins — the protocol a plugin behind a plugin element speaks.
- Service API explorer — try the run endpoints live.