Confluye
Endpoints

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.

MethodPathResult
GET/api/product-connections?workspaceId={ connections[] } (callback URLs, key IDs, lifetimes, trust; no secret)
POST/api/product-connections201 { 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:

FieldNotes
name1–120 characters, unique in the workspace
kindmcp or http
mcpServerIdkind: "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.
baseUrlkind: "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.
contextHeadersOptional 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.
callbackUrlOptional. 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.
mailBatchUrlOptional. 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_lost records reason: "no_longer_eligible". Until a call resolves it, status can still read trusted. 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. Under reviewed-mutations or read-only the 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 answers 404 product_connection_trust_workflow_not_found; a list that is empty, too long, repeated or malformed answers 400 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, with run: 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.

FieldRule
delegationRef1–128 characters of letters, digits, ., _, :, ~, -
usageTagA pseudonym: 1–64 characters of letters, digits, ., _, :, -. Never a name or an email.
subjectExternalIdsUp to 10 distinct external IDs with the end user external ID pattern
selectedCustomerExternalIdOptional. 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.

ClaimMeaning
ver1
issConfluye's public origin: the deployment that signed. Compare it with the one you trust
audThe product connection's origin, scheme://host[:port]
cidThe product connection ID
iat, expUnix seconds; exp is strictly greater than iat and exp - iat is at most 60
jtiUnique per request
htmHTTP method, upper case
htpPath and query of the request target, exactly as sent
bshbase64url(SHA-256(body bytes)); for no body, the hash of zero bytes (47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU)
workspaceIdThe 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
productContextThe object sent on execute, or null

Verifying a request

Apply every step, in order, before trusting any claim:

  1. Both headers are present and the context is at most 8192 characters (missing_header, malformed_header).
  2. 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).
  3. Decode the claims; ver is 1 (unsupported_version). Every claim has the type of the table above, and run, endUser and productContext are always present, each as an object of that shape or null (malformed_header).
  4. iss is exactly the origin of the Confluye deployment you trust (issuer_mismatch), aud is your own origin (audience_mismatch), cid is the connection ID Confluye gave you (connection_mismatch) and workspaceId is 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.
  5. exp is strictly greater than iat and exp - iat is at most 60 (ttl_too_long); allowing 5 seconds of clock skew, iat is not in the future (not_yet_valid) and exp has not passed (expired).
  6. htm, htp and bsh match the received method, request target and raw body bytes (method_mismatch, path_mismatch, body_mismatch). Hash the body before parsing it.
  7. jti was not seen before; keep each jti until its exp plus 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: kid rck_vectorKeyTwo, secret rcs_t2VbV3ZtV0x6c3Jn-current-secret-for-vectors
  • Request: POST /mcp?tenant=ignored with body {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"contacts.get","arguments":{"id":"c_1"}}}
  • Confluye-Run-Context-Signature: rck_vectorKeyTwo=I0njgznBD47af24h8Q7M-55a7No8B84O30seAwLXC6Q
  • Confluye-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 typeSent toWhen
run.completedcallbackUrlA run that was started with callbackUrl succeeded, failed or was canceled.
end_user.replycallbackUrlA conversational end user run with callbackUrl succeeded: the reply for the end user.
mail.batchmailBatchUrlA 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:

CredentialRead the runThe execute response was lost
Workspace API keyGET /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/hooks and https://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. callbackUrl on 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.completed and end_user.reply, any 2xx completes delivery. A mail.batch also requires the matching JSON batch/cursor acknowledgement; a plain 2xx retries. A delivery that is not confirmed is sent again with the same event id, so deduplicate on it. Every attempt is signed again, with a new jti.
  • 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-After header on a 429 or 503, as a whole number of seconds (digits only, so 1.5 is 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 is failed and 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.batch at 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.

ClaimMeaning
ver1
issConfluye's public origin: the deployment that signed
audThe product connection's origin, scheme://host[:port]
cidThe product connection ID
iat, expUnix seconds; exp is strictly greater than iat and exp - iat is at most 60
jtiUnique per delivery attempt
htmPOST
htpPath and query of the request target, exactly as sent
bshbase64url(SHA-256(body bytes))
evtThe event type: run.completed, end_user.reply or mail.batch
evvThe version of the event body, 1
eidThe event ID: the body's id, the same on every attempt
workspaceIdThe 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:

  1. Both headers are present and the Confluye-Callback value is at most 8192 characters (missing_header, malformed_header).
  2. 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).
  3. Decode the claims; ver is 1 (unsupported_version).
  4. iss is exactly the origin of the Confluye deployment you trust (issuer_mismatch), aud is your own origin (audience_mismatch), cid is the connection ID Confluye gave you (connection_mismatch) and workspaceId is 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.
  5. evt is a type this route accepts (event_type_mismatch) and evv is a version you support (unsupported_event_version). A signature for run.completed is refused on your batch route, and the other way round.
  6. exp is strictly greater than iat and exp - iat is at most 60 (ttl_too_long); allowing 5 seconds of clock skew, iat is not in the future (not_yet_valid) and exp has not passed (expired).
  7. htm, htp and bsh match the received method, request target and raw body bytes (method_mismatch, path_mismatch, body_mismatch). Hash the body before parsing it.
  8. 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 === evv and body.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.
  9. jti was not seen before; keep each jti until its exp plus 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
statussucceeded, failed or canceled
outputSummarynull 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.
errorThe 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
delegationRefThe 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.batchId equals the envelope's id. data.events[].id is <provider>:<kind>[.b]:<providerId>: it contains the provider, kind (in, out or cal), provider resource ID and, for backfill events, an optional .b marker. A mail ID names an immutable message in one direction. Deduplicate mail retries by provider, kind and provider ID, ignoring .b so 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, cal and 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[].type is a lowercase dotted name; the event's own data is 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, with mailboxRef of the form mbx_<connectionId>:mail or :calendar and cursors cur_<n>) are documented in Mailboxes and calendars.
  • data.events lists 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.from is where the batch starts (null for the first batch of a mailbox) and data.cursor.to is 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: kid rck_vectorKeyTwo, secret rcs_t2VbV3ZtV0x6c3Jn-current-secret-for-vectors
  • Request: POST /api/confluye/callback with Content-Type: application/json and the body below
  • Confluye-Callback-Signature: rck_vectorKeyTwo=cvP3bHhw3LKXPGH-MXQOJSq8_caIxICV4QFImYyjhLU
  • Confluye-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.