Theme
Open the portal

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

Zero-knowledge over the answers. The server stores only per-party ciphertext and relays whose turn it is. It cannot read an answer, cannot evaluate a condition, and cannot fill a template from plaintext — which is why document generation uses a one-time key you supply (see Generate the contract). The 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

  1. Trigger a run for a connection, binding every party to a user_id. The run pins the latest published flow version and its status becomes awaiting_<entry node's party>.
  2. 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.
  3. 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 becomes awaiting_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.

Contract flows need the Pro level or above. Authoring and starting a run are gated; a run that already exists can always be finished. Below Pro, starting a run answers exactly as if the flow did not exist.

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.

POST /api/company-data/flows/{flowId}/runs
Authorization: Bearer ey...
Content-Type: application/json

{"target": {"connection_id": "019xxxxxxxxxxxxxxxxxxxxxxxxx"},
 "bindings": {"company": "019aaa…", "customer": "019bbb…"}}
Statuserror_keyMeaning
400flows.bindings_invalidNo target was given, a party is left unbound, or a bound customer is not of the type its party declares.
400flows.no_counterpartyThe bindings name no participant besides your own company.
400flows.tag_values_invalidThe version's text shows the customer's values and flow_version or tag_values is missing or malformed.
400flows.source_files_invalidsource_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.
404flows.connection_not_foundThe target connection isn't connected to your service.
409flows.not_publishedThe flow has no published version.
409flows.version_changedA newer version was published after you read it: read …/published again, recompile and retry.
409flows.tag_values_staleA 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.
409account.counterparty_deletedThe 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.

GET /api/company-data/flow-runs/{runId}

One run in full — everything you need to render and route the current turn.

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:

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:

  1. Decrypt the run's existing answers (your service-key copies).
  2. Run the FlowConditionEvaluator over the current node's outgoing edges to pick the first matching edge → that gives you the next node.
  3. Fill the current node's answer(s).
  4. 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}).
  5. POST the answers with the computed next_node (or leaf: true on a terminal node).
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"}
Statuserror_keyMeaning
400contact_data.encryption_requiredA value is plaintext — every answer value must be a valid {"_enc":1,…} wrapper.
400flows.answer_party_missingAn answer is missing a copy for one of the bound parties (one value per party is required).
400flows.answer_invalidAn answer has no slug, or its source_private is present but not true or false.
400flows.answer_party_invalidA for_user_id is not a bound party of the run.
400flows.next_node_invalidnext_node is not a successor of the current node in the pinned definition.
403flows.not_your_turnThe run is awaiting a different party — not yours.
409flows.run_not_awaitingThe 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.

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.

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>"}]}
Statuserror_keyMeaning
400flows.generation_failedNo 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.
400flows.generate_inputs_mismatchThe 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.
400flows.source_pdf_invalidA participant PDF the matched rule names is not a usable PDF. The run stays at generating; the way out is cancelling the run.
409flows.not_generatableThe run isn't at a document-mode generatable leaf.
404flows.run_not_foundNo 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.

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.

Where a rule's PDF comes from

A rule carries exactly one PDF source:

Rule memberThe PDFSource key
asset_keya 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 stepfield:<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 startconn:<party>:<request_slug>

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.

An unsigned run-participant document's status is the run's to move. 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.
Person-side runs. The person's apps drive their turns through their own endpoints under /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.

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:

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):

EventWhenCarries
flow_run_turnA run became a party's turnflow_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_completedA 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"
}
These events are run-level: they carry 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" } } }

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:

KeyValueType
caoThe 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 valuetext for search_select and text; else the block's kind (number, date)
cao.<block>.idThe picked option's id — search_select blocks onlytext
cao.<output>The output's value; a null output adds no keythe 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.

  1. 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.
    A pass unlocks the plugins of the plugin elements on the run's current node, only while the run awaits your party there, and lives 10 minutes. The answer:
    { "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": {…} }} }
    specs is keyed by element slug. A plugin whose description could not be fetched is listed with public_key: null: its field is not responding. A step with no plugin element answers 200 with an empty plugins list.
    Statuserror_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.
  2. Seal the question — {field_type, op, block, query, picks, values, inputs, reply_key} (the plugin protocol) — to the plugin's public_key, with the public half of a fresh RSA-2048 reply key as reply_key. Inputs are read from your live answer map and converted to their declared types.
  3. Post it to {forwarder_url}/call as {"pass": "…", "plugin_id": "…", "request": "<wrapper string>"}, over a plain HTTP client that carries no allme credential — the pass rides the body. The answer is 200 {"reply": "<wrapper string>"}; open it with the reply key.
Forwarder statuserror_keyWhat to do
401plugin.pass_invalidGet a new pass once.
403plugin.not_in_passGet a new pass once.
409plugin.key_changedThe plugin's key changed; the body carries the new public_key. Reseal once with it.
400plugin.request_invalidThe call carried no sealed request.
404plugin.not_foundThe plugin does not exist.
429plugin.busy / plugin.rate_limitedThe plugin's in-flight cap, or your per-plugin window (60 calls a minute), is full.
502plugin.not_respondingThe plugin did not answer in time, answered an error, or has no stored description.
503plugin.unavailableThe forwarder cannot answer right now.
Another party's private value is never sent to a plugin. A value is private when its slug is in the run's 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.

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:

typeShapeResult
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

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

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.

Constants are author-only by default. They are computed client-side (or in the PDF filler) from data you already hold; the server neither stores nor transmits a constant's value. A participant only ever sees a constant where a step opts in via 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_keySeverityRule
constant.key_invaliderrorkey must match ^[a-z][a-z0-9_]*$
constant.key_duplicateerrorkey must be unique across all field slugs and all other constant keys
constant.ref_unknownerrorevery ref.key targets an existing field slug or constant key
constant.cycleerrorno cyclic references among constants
constant.ref_not_answerableerrorat each use site, a referenced constant must be available there
constant.shown_on_unknown_nodeerroreach shown_on entry must be an existing node key
constant.shown_on_not_computableerrorthe 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_mismatcherrorthe 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_deeperroran expression may nest at most 64 levels
constant.unusedwarningdefined but referenced by no condition or PDF tag
pdf.unknown_tagwarning(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_keySeverityRule
element.kind_invaliderroran element's kind is field, text, image or plugin
flow_plugin.unknownerrorthe element's plugin is missing, or is another company's
flow_plugin.field_type_unknownerrorthe plugin's current description has no such field type, or no description was fetched
flow_plugin.input_unwirederrora required plugin input is not wired
flow_plugin.input_not_answerableerroran input is wired to a key not answered before or on the element's node
flow_plugin.input_type_mismatcherroran input's source does not match its declared type
field.default_not_allowederrora default, minimum or maximum on a field where none is allowed (below)
field.default_type_mismatcherrorthe default's inferred type is not the field's
field.bound_type_mismatcherrora minimum or maximum on a field that is not a number or date field, or of another type
field.ref_not_answerableerrora default or bound references a key not answered at the field's node
field.expr_too_deeperrora default or bound nests more than 64 levels
dependency.cycleerrorplugin 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"} }

Next