Product connections
Register your product as an MCP server or an HTTPS base URL, verify the signed run context Confluye sends with every request to it, and receive signed callbacks when runs end.
A product connection marks an MCP server, or an HTTPS base URL, as your own product. Every request
Confluye sends to it carries a signed run context: which run, workflow, version and end user the request
belongs to, and the productContext your product sent when it started the run. The context travels in
request headers. It is never part of a prompt, a tool argument, a run DTO, a log or an export.
Requests to MCP servers and URLs that are not product connections are unchanged, except that a redirect from one of them into a registered connection is refused (see Redirects below).
Register, rotate and remove
Product connections are managed from a signed-in session by an explicit workspace Admin or Owner.
Each change requires a recent step-up (the credential step-up: registering and rotating issue a signing
secret), is sent as a same-origin JSON request, and writes an audit event (product_connection.create,
product_connection.rotate_secret, product_connection.delete). There is no API key route for these
changes.
| Method | Path | Result |
|---|---|---|
| GET | /api/product-connections?workspaceId= | { connections[] } (callback URLs, key IDs, lifetimes, trust; no secret) |
| POST | /api/product-connections | 201 { connection, signingSecret: { kid, secret } } |
| POST | /api/product-connections/{id}/rotate | { connection, signingSecret, previousKeysRetireAt } |
| DELETE | /api/product-connections/{id} | { deleted: true }; requests to it stop carrying a run context |
| PATCH | /api/product-connections/{id}/endpoints | { connection }; sets or clears the callback and batch URLs |
| PUT | /api/product-connections/{id}/trust | { connection }; the tools of an MCP connection run unattended |
| DELETE | /api/product-connections/{id}/trust | { connection }; the approval per call comes back |
Registration fields:
| Field | Notes |
|---|---|
name | 1–120 characters, unique in the workspace |
kind | mcp or http |
mcpServerId | kind: "mcp": a registered MCP server whose https:// endpoint has a fixed host and a path without templates, encoded slashes (%2F), encoded backslashes (%5C) or dot segments (./.., including %2e spellings). Such an endpoint answers 400 product_connection_endpoint_invalid. A workflow publication server cannot be a product connection. The connection records the endpoint path it was registered with and signs only requests to that path: if the server's endpoint is later edited to another path, even on the same origin, requests are refused before anything is sent (product_connection_origin_mismatch) until the connection is registered again. |
baseUrl | kind: "http": an absolute https:// URL without credentials, query or fragment. Requests to the same origin under this path prefix are signed, and a redirect is followed only under it. The path cannot contain an encoded slash (%2F) or backslash (%5C) or a dot segment (., .., %2e): such a baseUrl answers 400 product_connection_endpoint_invalid. Unique in the workspace: another HTTP connection with the same origin and path prefix answers 409 product_connection_http_target_taken. When nested prefixes match a request, the longest one signs it. A request that two equally specific connections match (possible only for connections registered before this rule) is not sent and fails with product_connection_http_target_ambiguous; delete one of them. |
contextHeaders | Optional object mapping header names to claims: runId, workflowId, versionId, endUserExternalId, delegationRef or usageTag. Up to eight headers may be bound; a claim may repeat under distinct header names. Empty claims omit the header. These convenience headers are unsigned: verify the signed run context before using them. |
callbackUrl | Optional. Where Confluye sends signed callbacks for run.completed and end_user.reply. An https:// URL with a path, on the same origin as the connection, without credentials, query or fragment. Unique in the workspace: a URL another connection already registers answers 409 product_connection_callback_url_taken. null clears it. |
mailBatchUrl | Optional. Where Confluye sends mail.batch deliveries. Same validation rules as callbackUrl except workspace uniqueness: several connections may register the same mailBatchUrl. A different path from callbackUrl is recommended: each route then accepts one event type. |
For example, include this field in POST /api/product-connections when registering an HTTPS connection:
{
"name": "CRM",
"kind": "http",
"baseUrl": "https://crm.example.com/api",
"contextHeaders": {
"X-Run-Id": "runId",
"X-Tenant": "endUserExternalId"
}
}
Header names use this ASCII HTTP-token syntax and must be distinct without regard to case:
^[!#$%&'*+.^_`|~0-9A-Za-z-]+$
Authorization, names beginning Confluye-Run-Context, and transport-managed names such as Host, Content-Type,
Cookie and Connection are refused. Any of the six claims may be copied to more than one distinct header name.
The limit is eight because it counts header bindings, not the number of available claims.
The contextHeaders values are convenience copies. They are not covered by the run-context signature and must never be
trusted independently, because a proxy or middleware can rewrite them. Receivers verify Confluye-Run-Context first, then use its claims or require each convenience header to equal its verified claim.
The destination must use HTTPS and a public host. http:// URLs, private, loopback, link-local, internal and
cloud metadata addresses, and names that resolve to them or do not resolve, are refused with
product_connection_endpoint_blocked or product_connection_endpoint_invalid. The same outbound policy runs on
every request: DNS is resolved and pinned for each request, a name that later resolves to a private address
is blocked before anything is sent, and every redirect hop is checked and must stay on HTTPS. An operator can
allow a legitimate private installation with FLUXUS_HTTP_EGRESS_ALLOWLIST.
The secret is shown once. Store it in your product's secret store; Confluye keeps only an encrypted copy.
An MCP registration is decided on the server as it is when the connection is stored: Confluye reads the server
again, locked, in the same transaction that creates the connection, so an edit made at the same moment cannot slip
between the check and the record. If that edit landed first, the registration follows the server as the edit left it.
A server that became a workflow publication server or switched to a transport a product cannot use answers
400 product_connection_invalid, a server that was deleted answers 404 product_connection_mcp_server_not_found,
and a server that now points to another host answers 409 product_connection_mcp_server_changed. Nothing is stored
and no secret is issued; check the server and register again.
Removing a connection, rotating its secret, changing its trust and updating its callback URLs are serialized per
connection. A rotation, a trust change or an endpoint update that arrives as the connection is removed answers
404 product_connection_not_found, as for any connection that does not exist.
An MCP server used by a product connection cannot be deleted directly (409 mcp_product_connection_attached).
Remove the product connection here first. Its unwithdrawn mailbox grants are retained without customer IDs or
addresses only for provider withdrawal, with the existing seven-day retry limit; then remove the MCP server.
Rotating the signing secret
POST .../rotate accepts overlapSeconds (0–604800, default 86400). It issues a new current key and returns
its secret once. Every key still in force signs alongside the new key, up to eight entries in the signature header.
Retain each known kid and secret separately. The response's connection.signingKeys lists each key's retiresAt;
use that key-specific deadline as its verifier notAfter. previousKeysRetireAt describes this rotation's overlap,
and an older key may retire earlier. Parse and verify any supported entry whose known key is still valid rather than
assuming the header contains only two keys. After every prior key's own overlap ends, only the current key signs.
An empty request body uses the default overlap. A non-empty body that is not a JSON object answers 400 and does not
rotate the key.
A connection signs with at most 8 keys at once: the current key and the keys still in a rotation overlap. A
verifier refuses a signature header with more than 8 entries, so a ninth usable key would make every request and every
callback fail verification. A rotation that would leave more than 8 usable keys is refused with
409 product_connection_signing_key_limit; nothing changes and no secret is issued. Rotate again after the earliest
overlap ends (the error names when), or send overlapSeconds: 0 to retire every previous key at once. A rotation
without overlap is never refused, so a leaked key can always be replaced. Confluye never shortens an overlap
silently: previousKeysRetireAt is always the end of the overlap you asked for.
A rotation waits for a callback POST that is already on its way (at most the 10 second send timeout). Product requests have no equivalent lifecycle lock: an MCP tool call can last minutes, so Confluye does not hold a connection lock across the product request.
A rotation takes effect on subsequent request signing, including runs already in progress. A request already signed
before a zero-overlap rotation or connection deletion may still be in flight and can fail at your product if it
arrives after the old secret is retired or the connection is removed. For rotation, a nonzero overlap avoids this
failure when your verifier keeps the previous secret valid through previousKeysRetireAt; during the overlap both
keys are included in new request signatures. Deletion has no overlap period and stops later signing. Registering a
connection also applies to subsequent requests: a request that matched no
connection is checked against the workspace's current connections before it is sent, so a request to a destination
you registered a moment ago is already signed, even in a run that started before you registered it.
Trusted connections
By default, an agent block that calls a tool on an MCP server waits for a Confluye approval, answered by the people who work in the workspace. A product that decides its own confirmations does not need that. An Admin can mark an MCP product connection as trusted for a list of workflows, and in those workflows its tools run without an approval per call. See Let your product decide the confirmations for what that means for an agent.
PUT /api/product-connections/{id}/trust takes { "workflowIds": ["wf_..."] }: one to 50 distinct workflows of
the workspace. It replaces any earlier list, so call it again to add or remove a workflow. It answers
{ connection }, whose trust is:
{
"status": "trusted",
"trustedAt": "2026-10-01T12:00:00.000Z",
"trustedById": "usr_...",
"workflowIds": ["wf_..."]
}
status is not_trusted, trusted, configuration_changed (the connection was edited after it was trusted;
it is treated as not trusted and is cleared the next time it is seen) or no_longer_eligible (the configuration is
unchanged, but the server can no longer be trusted, for example because it now publishes Confluye workflows; it is
treated as not trusted and is cleared the next time a run resolves it). DELETE .../trust removes the trust and
answers the same shape; repeating it changes nothing.
Same guards as the other changes on this page: Admin or Owner, a recent step-up, a same-origin JSON request. Each
change writes an audit event: product_connection.trust_grant, product_connection.trust_update (the list
changed while trusted), product_connection.trust_revoke, and product_connection.trust_lost when an edit, or a
workflow publication on the server, ended it. The event names the workflows and the actor, never the configuration or
a secret.
- The trust is bound to the connection's configuration when it is granted: the destination, the context headers and, for an MCP connection, the server's endpoint, transport and header templates. A different configuration, from any edit, ends it. An Admin has to grant it again; undoing the edit does not restore it. Rotating a signing secret, changing the server's tool list, enabling or disabling it, or changing its callback or batch URLs keep it.
- A trusted server must stay a product's server. If it becomes a Confluye workflow publication server, or a workflow
is published on it, after the trust was granted, the trust stops applying at the next tool call: that call asks for
the approval, the trust is cleared and
product_connection.trust_lostrecordsreason: "no_longer_eligible". Until a call resolves it,statuscan still readtrusted. An Admin cannot grant trust again while the server carries a publication (400 product_connection_trust_unsupported), and removing the publication does not restore the cleared trust. - A workflow that is not on the list, another workspace and every connection that is not trusted ask for the approval per call.
- It applies to agent blocks whose guardrail profile is
custom, which is also the profile of a block that has none chosen. The block's allowed-tool list, its budgets and the secret check still apply. Underreviewed-mutationsorread-onlythe approval stays. - When your product answers a tool call with
pending_confirmation, the agent gets that as the tool result. Your product owns that confirmation; Confluye does not wait for it. - A connection to an HTTPS base URL (
kind: "http") cannot be trusted:400 product_connection_trust_unsupported. An unknown workflow or one from another workspace answers404 product_connection_trust_workflow_not_found; a list that is empty, too long, repeated or malformed answers400 product_connection_trust_workflow_invalid. - Trust is checked again before every MCP tool call. Revocation or a configuration edit applies to the next tool call, including calls from runs that are already running or resume after being parked; that call requires Confluye approval instead of running unattended.
Codex CLI and Claude Code nodes
Codex CLI and Claude Code nodes use this trust for product connection tools through a temporary, per-node
execution MCP door. The CLI receives the loopback MCP endpoint, a bearer credential in its attachment
configuration, and only the selected product tools, under generated door-local names. It never receives the persisted run identity,
productContext, signing secret or signed request headers. Confluye signs each product request itself, including
tool discovery, and sends those signed headers directly to your product. They do not enter the CLI prompt, tool
arguments, run data or logs.
Before listing or calling tools, the door checks the connection's current workflow trust and configuration. It also blocks tool arguments that contain detected secrets. The node may combine product tools only with that run's saved-browser session. It cannot attach another MCP server. Validation, publication and run admission reject a product connection without trust for that workflow, so existing workflows may need their MCP attachments or trust updated before they can run. The door returns stable refusal codes as MCP tool errors, and records each refusal as a warning in the run's Logs with its code, tool and connection. A secret refusal records only field paths, pattern IDs and a digest, never the argument values.
Besides the selected tools (product_tool_1, product_tool_2, … which keep working unchanged), the door lists two
read-only discovery helpers for that node: product_tool_search (query, optional limit 1–25) finds selected tools
by name, title or description, and product_tool_describe (alias) returns one selected tool's current input schema,
output schema and annotations. They cover only the tools the node selected while the connection is trusted, read the
product's current tool list through the same signed path, and neither runs a tool, grants a permission nor reaches an
unselected or no longer offered tool. They return product_connection_discovery_invalid_input for a malformed query,
limit or alias, product_connection_tool_unavailable when the product no longer offers the selected tool, and
product_connection_tool_detail_too_large when a description is over the size the door returns (call the tool through
its alias instead); the other refusals are those of a tool call. A schema is the operation's general shape: it does
not say which fields the signed-in person may use or require, so publish that as an operation of your own product and
let the node call it through its alias.
Each resumed node execution opens a new door with the run's saved context and original delegation reference. The CRM owns delegation renewal by the signed run ID. Confluye adds no renewal channel. A product rejection, including an expired delegation, returns as a tool error. A CLI exit code of zero does not prove the product action succeeded.
Which requests carry the run context
- MCP product connections: every HTTP request of the MCP session: version negotiation,
tools/list,tools/call, notifications and session close. Discovery and tool tests from the MCP settings are signed too, withrun: null. - HTTP product connections: requests sent by HTTP Request blocks and by custom tools in AI agent blocks to a URL under the base URL, when live HTTP is enabled for the workspace.
Each request is signed for its own method, path and body, with a new jti and a lifetime of 60 seconds.
Paths with more than one meaning. The base path is the boundary a signature vouches for, and a proxy or framework
that decodes an encoded separator and then normalizes dot segments can read a path under the prefix as a path outside
it: /api/confluye/%2e%2e%2fadmin starts with the prefix as text, yet such a server routes it to /api/admin. Confluye
therefore refuses, before sending anything, a request to a registered HTTP connection whose path contains an encoded
slash (%2F) or backslash (%5C), in any letter case, a backslash, or a segment that is a dot segment once decoded
(. or .., including %2e spellings). The call fails with 400 product_connection_endpoint_blocked, and a redirect
to such a path fails with product_connection_redirect_refused (see below). Ordinary percent-encoded characters keep
working: %20, %C3%A9, and a %2e inside a name such as file%2ename are signed as written. A percent-encoded
percent sign (%25) is allowed only when no decoding stage yields an encoded separator, backslash, or dot segment. A
literal /../ in a URL is resolved by the HTTP client before the request is built, so the path that is matched, signed
and sent is the resolved one.
Redirects. The connection's destination is a boundary: Confluye follows a redirect only while it stays on the
connection's origin and under the same path prefix, and signs the new request again for its own path. For an HTTP
connection the prefix is the base path of its baseUrl: with https://crm.example.com/api/confluye, a redirect
from /api/confluye/a to /api/confluye/b is followed; one to /billing or /api/confluye-admin is not. For an MCP connection it is the path of the
server's endpoint. A redirect that leaves the prefix or the origin is refused: the call fails, and Confluye
sends nothing to the redirect target, not the run context and not the request body either. A request is never
forwarded unsigned. The same holds the other way round: a request to a URL that is not a product connection is sent
as before, but if one of its redirects leads into a registered connection's origin and path prefix, that redirect is
refused rather than followed unsigned. If your product moves an endpoint, update the connection's base URL or the MCP
server's endpoint instead of relying on a redirect.
Runs started without a productContext (manual, scheduled, webhook and copilot runs, reruns and child workflow
runs) still carry their run identity, with productContext: null and no delegation. A run without an end user
carries endUser: null.
productContext on execute
POST /api/v1/workflows/{id}/execute accepts productContext
beside input only from an end_user_gateway key with the run permission. Workspace keys and other callers that
send it receive 400 with code: "product_context_requires_product_credential". This is a behavior change:
workspace keys can no longer assert product context. Confluye validates four reserved fields and indexes the first
three on the run; every other field is opaque. The whole object is at most 2048 bytes of JSON.
| Field | Rule |
|---|---|
delegationRef | 1–128 characters of letters, digits, ., _, :, ~, - |
usageTag | A pseudonym: 1–64 characters of letters, digits, ., _, :, -. Never a name or an email. |
subjectExternalIds | Up to 10 distinct external IDs with the end user external ID pattern |
selectedCustomerExternalId | Optional. One of subjectExternalIds: the one customer your authenticated session selected for this run |
subjectExternalIds is the set of people the run touches, and what an erasure of a subject finds; it is never read
as the customer the run is about, even with one ID. selectedCustomerExternalId is the only field that selects a
customer for what the run remembers (see Remembered
facts). Send it only from your backend after your own authorization
of that customer. A run without it reads and writes no customer-scoped durable facts and no customer-specific work record
(it uses the agent's own facts and its entity-less work record). That guarantee covers only those two stores. The recent conversation and its rolling summaries belong to the agent, are
agent-wide and are not filtered per customer, so they can contain customer information whether or not a customer was
selected. The selection is part of the signed productContext and of the idempotency fingerprint, and no message, input
field or model output can set it.
A malformed value answers 400 with a code such as product_context_subject_external_ids_invalid or
product_context_selected_customer_invalid (the selection is malformed or not one of subjectExternalIds); the
message never repeats the value. A product gateway key with the run permission can recover a lost execute response
with GET /api/v1/runs?delegationRef=, limited to end-user runs
started by that same key from workflows on its allowlist. Other gateway keys receive 403. Workspace API keys keep
their workspace-scoped delegation lookup.
The signed run context (contract v1)
Version 1 is frozen. A change to a header, a claim, the signing input or a limit is a new version.
Confluye-Run-Context: <base64url(UTF-8 JSON claims), no padding>
Confluye-Run-Context-Signature: <kid>=<signature>[, <kid>=<signature>]
Each signature is base64url(HMAC-SHA256(key, input)), without padding, where key is the UTF-8 bytes of the
connection secret and input is the UTF-8 string confluye-run-context.v1.<kid>.<Confluye-Run-Context value>.
The signature covers the exact header bytes, so a verifier never re-serializes JSON. HMAC was chosen over JWS
because a rotation overlap needs two signatures on one request, and verification needs only a standard HMAC
function.
| Claim | Meaning |
|---|---|
ver | 1 |
iss | Confluye's public origin: the deployment that signed. Compare it with the one you trust |
aud | The product connection's origin, scheme://host[:port] |
cid | The product connection ID |
iat, exp | Unix seconds; exp is strictly greater than iat and exp - iat is at most 60 |
jti | Unique per request |
htm | HTTP method, upper case |
htp | Path and query of the request target, exactly as sent |
bsh | base64url(SHA-256(body bytes)); for no body, the hash of zero bytes (47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU) |
workspaceId | The Confluye workspace the connection belongs to. Compare it with the one you registered the connection in |
run | { id, workflowId, versionId }, or null outside a run |
endUser | { externalId }, or null |
productContext | The object sent on execute, or null |
Verifying a request
Apply every step, in order, before trusting any claim:
- Both headers are present and the context is at most 8192 characters (
missing_header,malformed_header). - At least one signature names a key you hold that is not past its
notAfter(unknown_key), and one of those signatures matches in constant time (bad_signature). - Decode the claims;
veris1(unsupported_version). Every claim has the type of the table above, andrun,endUserandproductContextare always present, each as an object of that shape ornull(malformed_header). issis exactly the origin of the Confluye deployment you trust (issuer_mismatch),audis your own origin (audience_mismatch),cidis the connection ID Confluye gave you (connection_mismatch) andworkspaceIdis exactly the Confluye workspace you registered the connection in (workspace_mismatch). Keep the issuer and the workspace in your configuration and never skip these two checks: a correctly signed request from another Confluye deployment or workspace, for example a staging copy that holds a clone of the connection secret, passes the signature and is refused only here.expis strictly greater thaniatandexp - iatis at most 60 (ttl_too_long); allowing 5 seconds of clock skew,iatis not in the future (not_yet_valid) andexphas not passed (expired).htm,htpandbshmatch the received method, request target and raw body bytes (method_mismatch,path_mismatch,body_mismatch). Hash the body before parsing it.jtiwas not seen before; keep eachjtiuntil itsexpplus the skew (replayed).
Then authorize the request from your own records: for example, look up the delegation named by
productContext.delegationRef. Never take a tenant or a permission from a claim alone.
The reference verifier is verifyProductRunContext in src/lib/product-connections/run-context.ts; it depends
only on node:crypto. Its expected options require issuer, workspaceId and connectionId beside audience, and
it throws when any of them is missing or empty instead of skipping the check. Two connections of one product can share
an origin, and key material copied between them signs for both: only the cid check tells their requests apart.
Test vector
Verify your implementation against this vector at Unix time 1790000030, as a product with origin
https://crm.example.com and connection ID pc_vector, trusting the issuer https://confluye.example and the
workspace ws_vector:
- Key:
kidrck_vectorKeyTwo, secretrcs_t2VbV3ZtV0x6c3Jn-current-secret-for-vectors - Request:
POST /mcp?tenant=ignoredwith body{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"contacts.get","arguments":{"id":"c_1"}}} Confluye-Run-Context-Signature:rck_vectorKeyTwo=I0njgznBD47af24h8Q7M-55a7No8B84O30seAwLXC6QConfluye-Run-Context:
eyJ2ZXIiOjEsImlzcyI6Imh0dHBzOi8vY29uZmx1eWUuZXhhbXBsZSIsImF1ZCI6Imh0dHBzOi8vY3JtLmV4YW1wbGUuY29tIiwiY2lkIjoicGNfdmVjdG9yIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjE3OTAwMDAwNjAsImp0aSI6Imp0aV92ZWN0b3JfMDAwMDAwMDAwMSIsImh0bSI6IlBPU1QiLCJodHAiOiIvbWNwP3RlbmFudD1pZ25vcmVkIiwiYnNoIjoiUXpQZVlta3RQc2RYemFxUUZ4dENxTWVjcEFmbGx6LTg0VjlFand0N3VmQSIsIndvcmtzcGFjZUlkIjoid3NfdmVjdG9yIiwicnVuIjp7ImlkIjoicnVuX3ZlY3RvciIsIndvcmtmbG93SWQiOiJ3Zl92ZWN0b3IiLCJ2ZXJzaW9uSWQiOiJ3dl92ZWN0b3IifSwiZW5kVXNlciI6eyJleHRlcm5hbElkIjoidF85ZjBjOnVfNDIifSwicHJvZHVjdENvbnRleHQiOnsiZGVsZWdhdGlvblJlZiI6ImRsZ18wMUo5VkVDVE9SIiwidXNhZ2VUYWciOiJ0YWdfN2YzYSIsInN1YmplY3RFeHRlcm5hbElkcyI6WyJ0XzlmMGM6cF8xIl0sImV2ZW50Ijp7InR5cGUiOiJjb250YWN0LmNyZWF0ZWQifX19
The decoded claims name run run_vector of workflow wf_vector, end user t_9f0c:u_42 and delegation
dlg_01J9VECTOR. Changing any byte of the context or the body must fail verification. The full set (a rotation
overlap with two signatures, a request outside a run, and the expected rejection for each failure above) is
tests/fixtures/product-run-context/v1-vectors.json in the Confluye repository. It includes two requests that are
correctly signed with the same key and must still be refused: foreign-issuer (iss is
https://staging.confluye.example, expect issuer_mismatch) and foreign-workspace (workspaceId is ws_other,
expect workspace_mismatch).
Signed callbacks
Confluye can tell your product when something it asked for is ready, with a signed POST to a URL you registered on
the connection. The channel has three event types, all version 1. The signature names the event type, so a route of
yours accepts only the types it expects.
| Event type | Sent to | When |
|---|---|---|
run.completed | callbackUrl | A run that was started with callbackUrl succeeded, failed or was canceled. |
end_user.reply | callbackUrl | A conversational end user run with callbackUrl succeeded: the reply for the end user. |
mail.batch | mailBatchUrl | A batch of mail or calendar events for one mailbox of an end user is ready, with a cursor. |
If a callback does not arrive
A callback never replaces the API. If one does not arrive, read the run with the credential that started it. The routes depend on the type of the credential:
| Credential | Read the run | The execute response was lost |
|---|---|---|
| Workspace API key | GET /api/v1/runs/{id} | GET /api/v1/runs?delegationRef= |
Product gateway key (end_user_gateway) | GET /api/v1/end-users/runs/{id} with the X-End-User-Id header of the run's end user (Poll or cancel a run) | Repeat the execute call with the same Idempotency-Key and body: the stored receipt, with the run ID, is returned instead of starting another run. With run permission, GET /api/v1/runs?delegationRef= also finds them |
Rolling deployment note: after the gateway-permission constraint migration lands, an old web process cannot create
product gateway keys because it does not write gatewayPermissions and the new check rejects the empty default. Wait
until the rollout has completed before creating new product gateway keys; existing gateway keys keep working.
The generic run routes answer a product gateway key with 403, except for the delegation lookup when it has run
permission. That lookup returns only end-user runs from the key's allowed workflows that the same API key started;
another key cannot read them even when its allowlist overlaps. A gateway key uses the gateway
route to read an individual run. On that route a run is visible only when its ID, the key that started it and its end
user all match: a missing or malformed X-End-User-Id answers 400, and the ID of another end user answers 404.
Register the endpoints
Register callbackUrl and mailBatchUrl with the connection, or later with
PATCH /api/product-connections/{id}/endpoints (same Admin, step-up and audit as every other change; the audit event
is product_connection.update_endpoints). Send only the fields to change; null clears one.
- Both URLs must be
https://, have a path, and be on the same origin as the connection (the origin every signature is bound to). A URL with credentials, a query, a fragment or a template, a private, loopback, link-local or metadata address, or a name that resolves to one, is refused. - The path is stored exactly as you send it (surrounding whitespace is trimmed, and the host is lowercased by URL
parsing). A trailing slash is kept:
https://crm.example.com/hooksandhttps://crm.example.com/hooks/are different URLs for different routes of your product, they can be registered on different connections, and callbacks go to the one you registered. A URL whose path is only/is refused, like the bare origin.callbackUrlon execute must match the stored value exactly, slash included. - The outbound network policy runs again on every delivery: DNS is resolved and pinned,
http://is never used, and a redirect is never followed. The signed request goes to the registered URL or nowhere. - A run keeps the URL it was started with. If you change or clear the URL while a run is still going, that run's
callback is not sent (its delivery fails with
endpoint_unregistered); register the new URL before you start runs that should use it. - Endpoint changes, secret rotations and connection removal wait for any callback POST already in progress (bounded by the send timeout). Once the change commits, no new request can leave for the previous endpoint, and none is signed with a key the rotation retired.
To ask for a callback, send callbackUrl on execute. Its value must
be exactly a callbackUrl registered on a connection of the key's workspace; any other value answers 400 with
code: "callback_url_not_registered" and creates no run. The registration is checked when a request starts a new
execution: repeating a completed request with the same Idempotency-Key after the URL was removed returns the
original receipt instead. This works with a workspace API key and with a product
gateway key. Because execute names the connection only by this URL, a callbackUrl is unique in the workspace:
registering it, or moving it with PATCH .../endpoints, onto a URL another connection of the workspace already
registers answers 409 with code: "product_connection_callback_url_taken", and nothing changes. Give each connection
its own path, or clear the URL from the other connection first. Another workspace may register the same URL, because
execute looks it up only inside the key's workspace. mailBatchUrl is not looked up by its URL (a mail batch is sent
for a connection already known), so it may be shared.
Delivery
- At least once. For
run.completedandend_user.reply, any2xxcompletes delivery. Amail.batchalso requires the matching JSON batch/cursor acknowledgement; a plain2xxretries. A delivery that is not confirmed is sent again with the same eventid, so deduplicate on it. Every attempt is signed again, with a newjti. - Backoff. After the first failure Confluye waits 10 seconds, then doubles the wait up to 30 minutes, for at most
12 attempts: the 11 waits add up to about two hours (2 hours 12 minutes 30 seconds). A
Retry-Afterheader on a429or503, as a whole number of seconds (digits only, so1.5is ignored) or an HTTP-date, lengthens that wait, up to one hour each time, so a product that keeps asking for the most can stretch the schedule to 11 hours (plus the time of the attempts themselves); a date already past, or a value that is neither form, asks for no extra wait. An unconfirmed response (including a mail batch's missing/mismatched acknowledgement), a timeout (10 seconds) and a network or egress error count as a failed attempt. After the last attempt the delivery isfailedand is not sent again: read the run from the API. A mail batch that Confluye cannot read yet, because the service that reads mailboxes is switched off while the batch waits, is not sent and does not use up an attempt: it is tried again about once a minute and goes out when the service is back. - References only. The outbox stores the run ID, or the mailbox, the cursor range and the provider's event IDs, a short error code and timestamps. It never stores a response body, an address, or the body of an email. A run's body is read from the run, and a mail batch's events from the mail provider, when the request is sent.
- Erasure. Deliveries of an erased end user are deleted with it and are never sent.
- Order. There is no ordering between runs. A mailbox has at most one unconfirmed
mail.batchat a time. Conversations and run results have their own, higher-priority delivery lane, so a slow mailbox never holds one back; the lanes run independently, so the order in which a mail batch and a run result reach you is not guaranteed.
The callback request (contract v1)
Version 1 is frozen. A change to a header, a claim, the signing input, an event type or a limit is a new version. The scheme is the one of the signed run context: the same per-connection secret, key IDs and rotation overlap, with its own header names and signing prefix, so a signature of one contract is never valid for the other.
POST /api/confluye/callback HTTP/1.1
Content-Type: application/json
Confluye-Callback: <base64url(UTF-8 JSON claims), no padding>
Confluye-Callback-Signature: <kid>=<signature>[, <kid>=<signature>]
Each signature is base64url(HMAC-SHA256(key, input)), without padding, where key is the UTF-8 bytes of the
connection secret and input is the UTF-8 string confluye-callback.v1.<kid>.<Confluye-Callback value>. During a
rotation overlap both keys sign every delivery.
| Claim | Meaning |
|---|---|
ver | 1 |
iss | Confluye's public origin: the deployment that signed |
aud | The product connection's origin, scheme://host[:port] |
cid | The product connection ID |
iat, exp | Unix seconds; exp is strictly greater than iat and exp - iat is at most 60 |
jti | Unique per delivery attempt |
htm | POST |
htp | Path and query of the request target, exactly as sent |
bsh | base64url(SHA-256(body bytes)) |
evt | The event type: run.completed, end_user.reply or mail.batch |
evv | The version of the event body, 1 |
eid | The event ID: the body's id, the same on every attempt |
workspaceId | The Confluye workspace |
The body is the signed envelope. Its id is a UUID, the same on every attempt of the same event.
{
"id": "6f1c1c0e-5b7a-4c1e-9a52-0000000000a1",
"type": "run.completed",
"version": 1,
"createdAt": "2026-10-01T12:00:05.000Z",
"workspaceId": "ws_abc",
"data": {}
}
Verifying a callback
Apply every step, in order, before trusting any claim:
- Both headers are present and the
Confluye-Callbackvalue is at most 8192 characters (missing_header,malformed_header). - At least one signature names a key you hold that is not past its
notAfter(unknown_key), and one of those signatures matches in constant time (bad_signature). - Decode the claims;
veris1(unsupported_version). issis exactly the origin of the Confluye deployment you trust (issuer_mismatch),audis your own origin (audience_mismatch),cidis the connection ID Confluye gave you (connection_mismatch) andworkspaceIdis exactly the Confluye workspace you registered the connection in (workspace_mismatch). Never skip the issuer and workspace checks: a correctly signed callback from another Confluye deployment or workspace, for example a staging copy that holds a clone of the connection secret, passes the signature and is refused only here.evtis a type this route accepts (event_type_mismatch) andevvis a version you support (unsupported_event_version). A signature forrun.completedis refused on your batch route, and the other way round.expis strictly greater thaniatandexp - iatis at most 60 (ttl_too_long); allowing 5 seconds of clock skew,iatis not in the future (not_yet_valid) andexphas not passed (expired).htm,htpandbshmatch the received method, request target and raw body bytes (method_mismatch,path_mismatch,body_mismatch). Hash the body before parsing it.- The body is the envelope the claims describe. Only now parse the body, which must be a JSON object, and require
body.id === eid,body.type === evt,body.version === evvandbody.workspaceId === workspaceId(envelope_mismatch). The envelope repeats four signed claims; a body that differs from them is refused even though its hash and signature are valid, so you never process an event type, version, workspace or ID that Confluye did not sign. Read the event from the verified body only after this step. jtiwas not seen before; keep eachjtiuntil itsexpplus the skew (replayed).
Then deduplicate on eid, which is the body's id. After durable storage, run.completed and end_user.reply may
answer with any 2xx. A mail.batch must answer with a 2xx JSON acknowledgement containing the exact batchId and
cursor described in the mail.batch section below; a plain 2xx does not confirm it. Authorize from your own records:
the delegation named by delegationRef, the tenant that owns the end user's external ID. Never take a tenant or a
permission from a claim alone.
The reference verifier is verifyRunCallback in src/lib/run-callbacks/signature.ts; it depends only on node:crypto
and applies every step above, including the envelope comparison of step 8 (it needs the raw body in expected). Its
expected options require issuer, workspaceId and connectionId beside audience, eventType and the request,
and it throws when any of them is missing or empty instead of skipping the check. Two connections of one product can
share an origin, and key material copied between them signs for both: only the cid check tells their callbacks apart.
run.completed
Sent when a run that was started with callbackUrl reaches succeeded, failed or canceled, in the same
transaction that ends it. A run parked at a wait is not finished and sends nothing yet.
The request body is the complete envelope, exactly as sent: id, type, version, createdAt
(when the run finished) and workspaceId at the root, and everything about the run under data. Read the fields in the
table below from data, never from the root.
{
"id": "6f1c1c0e-5b7a-4c1e-9a52-0000000000a1",
"type": "run.completed",
"version": 1,
"createdAt": "2026-10-01T12:00:05.000Z",
"workspaceId": "ws_abc",
"data": {
"runId": "run_abc",
"workflowId": "wf_abc",
"versionId": "wv_abc",
"version": 3,
"status": "succeeded",
"startedAt": "2026-10-01T12:00:00.000Z",
"finishedAt": "2026-10-01T12:00:05.000Z",
"durationMs": 5000,
"outputSummary": { "bytes": 17, "truncated": false, "value": { "answer": "Done" } },
"error": null,
"connectionRequired": null,
"delegationRef": "dlg_01J9VECTOR",
"endUser": { "externalId": "t_9f0c:u_42" }
}
}
The version in data is the workflow version number; the one at the root is the version of the event body (evv).
Field (in data) | Meaning |
|---|---|
status | succeeded, failed or canceled |
outputSummary | null without output; otherwise bytes (the UTF-8 size of the output as compact JSON, as JSON.stringify writes it: {"answer":"Done"} is 17) and value, present only when the output is at most 4096 bytes. truncated: true means it is not. Read the full output from the API. |
error | The error of a failed run, at most 500 characters, or null |
connectionRequired | { reason, siteKey } when the run failed for lack of the end user's website login, or null |
delegationRef | The productContext.delegationRef the run was started with, or null. It lets you bind or reconcile a delegation that did not yet have a runId. |
endUser | { externalId } of the end user the run acted for, or null |
end_user.reply
Sent beside run.completed when a conversational end user run succeeds: the reply you relay to the end user. Event
runs (mode: "event") and runs that failed or were canceled send no reply. The reply is the run's output, up to 65,536
bytes.
The request body is the complete envelope, like every callback: the event is under data, not at the root.
{
"id": "6f1c1c0e-5b7a-4c1e-9a52-0000000000a2",
"type": "end_user.reply",
"version": 1,
"createdAt": "2026-10-01T12:00:05.000Z",
"workspaceId": "ws_abc",
"data": {
"runId": "run_abc",
"workflowId": "wf_abc",
"endUser": { "externalId": "t_9f0c:u_42" },
"delegationRef": null,
"reply": { "bytes": 17, "truncated": false, "value": { "answer": "Done" } }
}
}
data.reply has the shape of outputSummary; when truncated is true, read the run with the end user poll route.
mail.batch
A batch of events of one mailbox of an end user, with the cursor range it covers. It is sent to mailBatchUrl.
Confluye reads the events from the mail provider when it sends the batch; its outbox keeps only the mailbox, the cursor
range and the provider's event IDs.
The request body is the complete envelope, like every callback: the batch is under data, not at the root.
{
"id": "6f1c1c0e-5b7a-4c1e-9a52-0000000000b2",
"type": "mail.batch",
"version": 1,
"createdAt": "2026-10-01T12:00:05.000Z",
"workspaceId": "ws_abc",
"data": {
"batchId": "6f1c1c0e-5b7a-4c1e-9a52-0000000000b2",
"endUser": { "externalId": "t_9f0c:u_42" },
"mailboxRef": "mbx_conn_1:mail",
"cursor": { "from": "cur_100", "to": "cur_142" },
"events": [{ "id": "gmail:in:18c5a", "type": "email.inbound", "data": {} }]
}
}
data.batchIdequals the envelope'sid.data.events[].idis<provider>:<kind>[.b]:<providerId>: it contains the provider, kind (in,outorcal), provider resource ID and, for backfill events, an optional.bmarker. A mail ID names an immutable message in one direction. Deduplicate mail retries by provider, kind and provider ID, ignoring.bso a backfill replay and a live delivery refer to the same message. A calendar ID names the event resource, not a change occurrence. Use provider,caland provider ID as the record key, ignore.b, and apply every upsert or deletion in cursor order; calendar changes reuse that ID and carry the current state, so the latest delivery updates the record. Confluye does not supply a separate calendar change-occurrence ID.data.events[].typeis a lowercase dotted name; the event's owndatais an object whose shape depends on the type. The events of mailboxes and calendars that end users connect (email.inbound,email.sent,calendar.event.upserted,calendar.event.deleted, withmailboxRefof the formmbx_<connectionId>:mailor:calendarand cursorscur_<n>) are documented in Mailboxes and calendars.data.eventslists the events in the order Confluye listed them for the batch, so you can apply them front to back. A message deleted at the provider since it was listed is left out; Confluye never sends the events of a batch in another order.data.cursor.fromis where the batch starts (nullfor the first batch of a mailbox) anddata.cursor.tois where the mailbox moves once you confirm the batch.
Confirm a batch by acknowledging it. Answer 200 with a JSON body naming the batch and its cursor, after you have
stored the events:
{ "ack": { "batchId": "6f1c1c0e-5b7a-4c1e-9a52-0000000000b2", "cursor": "cur_142" } }
Only then does Confluye record cursor.to as the confirmed cursor of the mailbox and let the next batch start from
it. A 2xx without a matching ack, or with another batch ID or cursor, is not a confirmation: the batch is sent
again, from the same cursor, with the same batchId. At most one batch per mailbox is unconfirmed at a time; a batch
that runs out of attempts is failed, the cursor stays where you last confirmed, and the next batch starts from it
again. An acknowledgement is only applied to a batch that starts at the cursor Confluye holds for the mailbox: for a
batch that names a data.cursor.from while Confluye holds no confirmed cursor for that mailbox, or another one, the batch is
failed and the cursor does not move, so a range of events is never skipped.
Test vector
Verify your implementation against this vector at Unix time 1790856060, as a product with origin
https://crm.example.com and connection ID pc_vector, trusting the issuer https://confluye.example and the
workspace ws_vector, expecting the type run.completed:
- Key:
kidrck_vectorKeyTwo, secretrcs_t2VbV3ZtV0x6c3Jn-current-secret-for-vectors - Request:
POST /api/confluye/callbackwithContent-Type: application/jsonand the body below Confluye-Callback-Signature:rck_vectorKeyTwo=cvP3bHhw3LKXPGH-MXQOJSq8_caIxICV4QFImYyjhLUConfluye-Callback:
eyJ2ZXIiOjEsImlzcyI6Imh0dHBzOi8vY29uZmx1eWUuZXhhbXBsZSIsImF1ZCI6Imh0dHBzOi8vY3JtLmV4YW1wbGUuY29tIiwiY2lkIjoicGNfdmVjdG9yIiwiaWF0IjoxNzkwODU2MDMwLCJleHAiOjE3OTA4NTYwOTAsImp0aSI6Imp0aV9jYl92ZWN0b3JfMDAwMDAxIiwiaHRtIjoiUE9TVCIsImh0cCI6Ii9hcGkvY29uZmx1eWUvY2FsbGJhY2siLCJic2giOiJ1WnNwa0NhRW94VkIwX3FIQk9LVmdwYmpZSTBxWWtrLWVqZDhwblhWTlU0IiwiZXZ0IjoicnVuLmNvbXBsZXRlZCIsImV2diI6MSwiZWlkIjoiNmYxYzFjMGUtNWI3YS00YzFlLTlhNTItMDAwMDAwMDAwMGExIiwid29ya3NwYWNlSWQiOiJ3c192ZWN0b3IifQ
Body, the exact signed bytes (one line, no trailing newline):
{"id":"6f1c1c0e-5b7a-4c1e-9a52-0000000000a1","type":"run.completed","version":1,"createdAt":"2026-10-01T12:00:05.000Z","workspaceId":"ws_vector","data":{"runId":"run_vector","workflowId":"wf_vector","versionId":"wv_vector","version":3,"status":"succeeded","startedAt":"2026-10-01T12:00:00.000Z","finishedAt":"2026-10-01T12:00:05.000Z","durationMs":5000,"outputSummary":{"bytes":17,"truncated":false,"value":{"answer":"Done"}},"error":null,"connectionRequired":null,"delegationRef":"dlg_01J9VECTOR","endUser":{"externalId":"t_9f0c:u_42"}}}
These are the 538 bytes of the request body, UTF-8 (all of them ASCII), written as JSON.stringify writes them: no
whitespace between tokens, no byte order mark and no trailing newline. The last byte is the closing }; do not add a
line feed or carriage return when you copy the line. The SHA-256 of these bytes, as base64url without padding, is
uZspkCaEoxVB0_qHBOKVgpbjYI0qYkk-ejd8pnXVNU4, the bsh claim. Hash and sign the bytes, never a re-serialization of the parsed JSON.
The same bytes as base64 (standard alphabet, with padding), for copying without a Markdown renderer or an editor changing the line:
eyJpZCI6IjZmMWMxYzBlLTViN2EtNGMxZS05YTUyLTAwMDAwMDAwMDBhMSIsInR5cGUiOiJydW4uY29tcGxldGVkIiwidmVyc2lvbiI6MSwiY3JlYXRlZEF0IjoiMjAyNi0xMC0wMVQxMjowMDowNS4wMDBaIiwid29ya3NwYWNlSWQiOiJ3c192ZWN0b3IiLCJkYXRhIjp7InJ1bklkIjoicnVuX3ZlY3RvciIsIndvcmtmbG93SWQiOiJ3Zl92ZWN0b3IiLCJ2ZXJzaW9uSWQiOiJ3dl92ZWN0b3IiLCJ2ZXJzaW9uIjozLCJzdGF0dXMiOiJzdWNjZWVkZWQiLCJzdGFydGVkQXQiOiIyMDI2LTEwLTAxVDEyOjAwOjAwLjAwMFoiLCJmaW5pc2hlZEF0IjoiMjAyNi0xMC0wMVQxMjowMDowNS4wMDBaIiwiZHVyYXRpb25NcyI6NTAwMCwib3V0cHV0U3VtbWFyeSI6eyJieXRlcyI6MTcsInRydW5jYXRlZCI6ZmFsc2UsInZhbHVlIjp7ImFuc3dlciI6IkRvbmUifX0sImVycm9yIjpudWxsLCJjb25uZWN0aW9uUmVxdWlyZWQiOm51bGwsImRlbGVnYXRpb25SZWYiOiJkbGdfMDFKOVZFQ1RPUiIsImVuZFVzZXIiOnsiZXh0ZXJuYWxJZCI6InRfOWYwYzp1XzQyIn19fQ==
The same body formatted for reading only (the whitespace is not part of the signed bytes, so do not hash or sign this form):
{
"id": "6f1c1c0e-5b7a-4c1e-9a52-0000000000a1",
"type": "run.completed",
"version": 1,
"createdAt": "2026-10-01T12:00:05.000Z",
"workspaceId": "ws_vector",
"data": {
"runId": "run_vector",
"workflowId": "wf_vector",
"versionId": "wv_vector",
"version": 3,
"status": "succeeded",
"startedAt": "2026-10-01T12:00:00.000Z",
"finishedAt": "2026-10-01T12:00:05.000Z",
"durationMs": 5000,
"outputSummary": { "bytes": 17, "truncated": false, "value": { "answer": "Done" } },
"error": null,
"connectionRequired": null,
"delegationRef": "dlg_01J9VECTOR",
"endUser": { "externalId": "t_9f0c:u_42" }
}
}
The decoded claims name the event 6f1c1c0e-5b7a-4c1e-9a52-0000000000a1 (evt run.completed, evv 1). The same
vector fails on a route that expects mail.batch (event_type_mismatch), at another path (path_mismatch), with any
changed byte of the body (body_mismatch) and with any changed byte of the header (bad_signature). The full set (a
mail.batch delivery, a rotation overlap with two signatures, and the expected rejection for each failure above) is
tests/fixtures/run-callbacks/v1-vectors.json in the Confluye repository. It includes two callbacks that are
correctly signed with the same key and must still be refused: foreign-issuer (iss is
https://staging.confluye.example, expect issuer_mismatch) and foreign-workspace (workspaceId is ws_other,
expect workspace_mismatch). Four more are correctly signed callbacks whose body disagrees with their own claims, and
each must be refused with envelope_mismatch: inconsistent-event-id (the body's id is not eid),
inconsistent-event-type (type is not evt), inconsistent-event-version (version is not evv) and
inconsistent-workspace (the body's workspaceId is not the signed one).
Next steps
MCP session cleanup remains signed and scoped to its established session. Closing a session after a dispatched tool call does not re-run workflow approval: a cleanup refusal must not turn a possibly committed effect into an apparent safe retry. New tool and initialization requests still check current authority before dispatch.
Deleting a product connection first retains its unwithdrawn mailbox grants as durable orphan revocation work, then removes mailbox rows and the parent in the same transaction. Direct database deletion is refused while mailbox rows exist. Mail batch acknowledgements over 4,096 bytes are rejected, including valid JSON prefixes with trailing data; their cursors remain unchanged and delivery retries with ack_too_large.
