End users
Run workflows for your product's customers, poll and cancel their runs, request website login links, export a customer's data, and erase a customer, a whole tenant or a subject.
These routes let a product act for its own customers (end users). They exist only while the deployment runs with
FLUXUS_END_USERS=true; see End users and memory for the concepts.
Product gateway key
Every route on this page requires a product gateway key (scope: "end_user_gateway"), sent as
Authorization: Bearer hise_…. An Admin or Owner creates it with the session endpoint POST /api/api-keys:
{
"workspaceId": "ws_123",
"name": "Telegram bot",
"scope": "end_user_gateway",
"allowedWorkflowIds": ["wf_abc"],
"permissions": ["run"]
}
permissions is required and says what the key may do for the workspace's end users. If it includes run or is
memory, allowedWorkflowIds must list at least one workflow of that workspace. For an erase-only or export-only
key, omit allowedWorkflowIds or pass an empty list; that key has no workflow authority:
| Permission | Allows |
|---|---|
run | Transcribe bounded product audio, run the allowed workflows for an end user, poll, cancel and re-run their runs, request website or mailbox connection links and poll their status, list, read or disconnect mailboxes, and submit user-approved email with an explicit sending grant. |
erase | Erase one end user, every end user of a tenant prefix, or a subject, and poll those erasures. |
export | Export the data stored for one end user. |
memory | List, correct, classify and delete what Confluye remembers about one end user, in the memory resources connected to the key's allowed workflows. See Remembered facts. |
Give each key only what its service needs: the web service that talks to customers gets run, and a worker that
handles privacy requests gets erase or export. Erasing and exporting use different keys, so a key cannot have
both erase and export. memory is a dedicated key: it is never combined with run, erase or export (a list
that mixes them answers 400), and no existing key gains it. A memory key cannot run a workflow or a skill, erase or
export, and a run, erase or export key cannot reach the memory route. A route answers 403 with
code: "gateway_permission_required" and the missing permission when the key does not have it. Keys created before
permissions existed keep what they always could do, run and erase, and cannot export; create a new key with
export to export, or with memory to manage remembered facts.
The scope, the allowlist and the permissions are fixed at creation; to change them, create a new key and revoke the old one. A product gateway key:
- runs only its allowed workflows, and only for an end user;
- reads a run only through the gateway status route below, only for runs it started, and only for the end user that owns the run;
- reaches only its own workspace: it never sees, exports or erases an end user of another workspace;
- is refused (
403) by every other v1 route exceptGET /api/v1/runs?delegationRef=...when it hasrunpermission, and by the MCP endpoints. The delegation lookup returns only end-user runs in the key's allowed workflows that the same key started. It is the only generic-route exception and does not allow reading an individual run throughGET /api/v1/runs/{id}; - stops working when its creator loses explicit workspace access, and while end users are disabled (
403).
A product may create at most 60 new end users per key per minute; the next call answers 429 with Retry-After.
Product MCP
POST /api/v1/end-users/mcp provides Streamable HTTP MCP for the recent customer calendar and saved-session
features. It requires a Product gateway key with run permission and at least one allowed workflow. It uses
the same bearer verification, live membership checks and operation-specific HTTP handlers as the routes below.
Workspace, organization, personal and non-run product keys are refused. GET returns 405; there is no
OAuth or SSE GET login for this endpoint. Keep the Product MCP connection separate from administrative MCP.
| Tool | Arguments and behavior |
|---|---|
calendar.upsert-event | The calendar create/update body below plus confirm: true; retains eventKey and idempotencyKey. |
calendar.delete-event | The calendar delete body below plus confirm: true. |
workflows.execute | workflowId, endUser, the execution fields below, confirm: true and idempotencyKey. Supports productContext and callbackUrl under their existing contracts. |
runs.get | runId and endUser; returns only this key's customer-run projection. |
runs.cancel | runId, endUser and confirm: true. |
runs.artifact | runId, endUser, artifactId, optional offset and maxBytes (at most 65,536). Returns base64 content, bytesRead, totalBytes, mimeType and nextOffset; repeat until nextOffset is null. |
browser.status | endUser and siteKey; reads the saved-login status for a site granted to the key's workflows. |
browser.connect | endUser, siteKey and confirm: true; requests the customer's private one-use login link. |
mail-connections.list | endUser; returns connection metadata and calendar-write consent capabilities. |
Every endUser is { "externalId": "your-customer-id" }. It stays in the MCP request body. The adapter
supplies the HTTP end-user header where needed; it accepts no caller-supplied workspace or internal subject ID.
The original product key and customer must still match a run, including during artifact retrieval. A workflow
call is re-authorized against that key's allowed workflows. Registered website routines execute through their
published workflows, with the saved customer's session and existing site grants; MCP adds no free-form browser
automation or new provider routine adapters.
Example calendar call, after the customer granted calendar_write:
{
"jsonrpc": "2.0",
"id": "appointment-1",
"method": "tools/call",
"params": {
"name": "calendar.upsert-event",
"arguments": {
"endUser": { "externalId": "customer-123" },
"connectionId": "connection-id",
"eventKey": "appointment-1",
"idempotencyKey": "appointment-1-v1",
"confirm": true,
"event": {
"title": "Property visit",
"start": "2026-11-03T10:00:00-05:00",
"end": "2026-11-03T11:00:00-05:00",
"timeZone": "America/Toronto"
}
}
}
}
Successful calls return MCP text and structuredContent. A refusal from the canonical operation is a tool
result with isError: true and the existing safe error body; invalid MCP arguments use JSON-RPC -32602.
Initialization and discovery recheck the key, and each operation rechecks its own authority. Responses are
not cached. The global FLUXUS_MCP_SERVER=false stop applies before authorization. Reconnect or rescan the
MCP client after deploying new tools.
Transcribe product audio
POST /api/v1/end-users/transcriptions accepts one audio file as multipart/form-data, plus optional
language (ISO-639-1) and durationMs. Send Authorization with a product gateway run key,
X-Workflow-Id with one of its allowed workflows, X-End-User-Id with the product's verified agent identity,
and a stable Idempotency-Key for that audio. Workspace, memory, erase and export keys cannot use it.
The workspace comes from the verified credential; a workflow from another workspace is refused.
The upload is bounded while being read: 10 MB and 60 seconds, verified from the actual container,
not the supplied duration. Supported containers include WAV, WebM Opus, MP4 audio, Ogg Opus voice notes
and MPEG Layer III MP3. A workspace speech-to-text provider must be connected in Settings → BYOK;
a Codex/Claude subscription credential alone does not supply speech transcription. OpenAI uses
gpt-4o-mini-transcribe; other compatible providers use their existing model mapping.
A successful response is { "text": "…" }. Treat this machine-generated text as untrusted user content:
it may mishear names, numbers or addresses. Keep the same end-user identity and ordinary product
permissions when passing it to a workflow. Clarify missing or ambiguous data before making a change.
This utility does not run a workflow, send email or grant product tools.
Identical requests replay the transcript for up to 24 hours without a second provider call. The receipt
is bound to the end user and removed by their erasure. Unlike usage telemetry, this temporary receipt
contains the transcript; it contains no audio bytes. Reusing a key with another audio, agent, credential,
workflow or language is a conflict. An uncertain provider result is quarantined so a retry cannot charge
again automatically. Return a clear error to the product user instead of silently dispatching an empty
request. 409 can indicate missing provider configuration or an idempotency conflict; 413 rejects size
or duration, 422 an unverifiable recording, and 429 throttling. The best-effort ingress limit is 12
requests per credential per minute per server instance; successful provider work is metered separately.
Endpoints
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/workflows/{id}/execute | Run an allowed workflow for an end user (queued) |
| GET | /api/v1/runs?delegationRef={ref} | Find this key's end-user runs for a delegation; requires run, same-key ownership and an allowed workflow |
| GET | /api/v1/end-users/runs/{id} | Status and outcome of one of the key's runs |
| DELETE | /api/v1/end-users/runs/{id} | Cancel one of the key's runs |
| POST | /api/v1/runs/{id}/rerun | Re-run one of the key's runs for the same end user |
| POST | /api/v1/end-users/connect-requests | One-use website login link for an end user and a site |
| GET | /api/v1/end-users/connect-requests/{id} | Poll whether the customer saved that new login |
| GET | /api/v1/end-users/browser-connections?siteKey= | Stored website login state of one end user for one site |
| POST | /api/v1/end-users/mail-connect-requests | One-use link to connect an end user's mailbox |
| GET | /api/v1/end-users/mail-connect-requests?id={id} | State of one mailbox link |
| POST | /api/v1/end-users/mail-connections | List an end user's connected mailboxes |
| POST | /api/v1/end-users/mail-connections/imap | Verify and connect an IMAP mailbox, with optional explicit SMTP sending access |
| GET | /api/v1/end-users/mail-connections?id={id} | One mailbox connection and its state |
| POST | /api/v1/end-users/mail-send | Send an email approved in your product, with a durable idempotency key |
| POST | /api/v1/end-users/mail-attachments | List or retrieve a file from an active end user's mailbox |
| POST | /api/v1/end-users/calendar-events | Create or update an event your product schedules, by your own event key (calendar_write) |
| DELETE | /api/v1/end-users/calendar-events | Delete the event written for one of your event keys |
| DELETE | /api/v1/end-users/mail-connections?id={id} | Disconnect a mailbox |
| POST | /api/v1/end-users/erasures | Erase an end user, a tenant prefix or a subject |
| GET | /api/v1/end-users/erasures?id={id} | Poll the erasure of one end user |
| GET | /api/v1/end-users/erasures?bulkId={id} | Poll an erasure by tenant prefix |
| GET | /api/v1/end-users/erasures?subjectId={id} | Poll an erasure by subject |
| GET | /api/v1/end-users/memory | List the remembered facts of one end user (X-End-User-Id); requires memory |
| PATCH | /api/v1/end-users/memory | Correct a fact's value and/or classify it, with the revision you read; requires memory |
| DELETE | /api/v1/end-users/memory | Delete one fact, with the revision you read; requires memory |
| POST | /api/v1/end-users/exports | Export a bounded snapshot or paginate an end user’s stored records |
End user IDs
endUser.externalId is the ID your product uses for the customer: 1–128 characters of letters, digits, and
. _ - : @ | + =. Routes that do not take a JSON body read it from the X-End-User-Id header, so it never appears in
a URL. Treat it as customer data: Confluye never writes it to audit events or logs.
Run a workflow for an end user
POST /api/v1/workflows/{id}/execute
{
"endUser": { "externalId": "tg-12345" },
"message": "Switch the form to the buyer I mentioned",
"input": { "prompt": "Switch the form to the buyer I mentioned" }
}
| Field | Required | Description |
|---|---|---|
endUser.externalId | Yes | The customer the run acts for. A new ID creates the end user. |
message | For conversations | The customer's message, 1–32,000 characters and not blank. With the reply block's output it forms the turn recorded in memory. |
mode | No | "event" runs the workflow with this end user's memory without recording a conversation turn. Omit it for the existing conversational behavior. An event may include an optional message to rank relevant facts. |
input | No | The workflow input, as for any execution. Pass the message here too when blocks read it from input. |
productContext | No | Your product's context for the run, as on any execution. It is signed only into requests to your product connections. Its reserved selectedCustomerExternalId scopes what the run remembers to one customer; see Remembered facts. |
callbackUrl | No | A callback URL registered on a product connection. Confluye sends a signed run.completed callback, and for a conversation that succeeds an end_user.reply callback, instead of making you poll. |
The end user is the identity of the run, not part of input: a block or a model cannot change it, and an
endUserId field inside input is ignored. End user runs always execute the active version: dryRun: true or
target: "candidate" answers 400. Conversational runs require a nonblank message; event runs can omit it.
Idempotency-Key is supported. Its fingerprint covers the whole execution request: input, startNodeId, target, dryRun, expectedVersionId, executionTargetId, confirm, endUser, message, mode, productContext, callbackUrl and the API key that sent it. Repeating a key with a change to any of them answers 409 (code: "idempotency_replay_mismatch") instead of replaying the earlier run. A stored receipt belongs to that end user,
so an erased customer's key is never replayed for a new customer with the same ID.
202 — the run was queued:
{
"run": {
"id": "run_1",
"workflowId": "wf_abc",
"status": "queued",
"startedAt": "…",
"finishedAt": null,
"durationMs": null
},
"queued": true,
"status": "queued",
"active": true,
"pollAfterMs": 1000,
"links": {
"self": "/api/v1/end-users/runs/run_1",
"cancel": "/api/v1/end-users/runs/run_1"
},
"statusUrl": "/api/v1/end-users/runs/run_1",
"cancelUrl": "/api/v1/end-users/runs/run_1",
"cancelMethod": "DELETE"
}
Start and rerun receipts include the same safe outcome fields as polling: status, active, pollAfterMs,
and output, error, or connectionRequired when available. They never include inputs, steps or logs.
Add callbackUrl (a callback URL registered on a product connection)
to be told when the run ends instead of polling: a signed run.completed callback, and for a conversational run that
succeeds, an end_user.reply callback with the reply to relay to the end user. Event runs send only run.completed.
Callbacks carry the end user's externalId and the run's delegationRef, never the input, steps or logs, and an
erased end user's pending callbacks are deleted, not sent. Polling stays available.
An occupied Claude Code or Codex credential does not delay acceptance of an end user run. The worker checks the CLI login before execution, so an accepted run can still fail if that login is unavailable. Poll the returned run ID to follow its outcome.
Runs of one end user start in the order they were sent. Conversations record their reply as a turn; event runs use
the same FIFO and memory but do not add a conversation turn. An event run may hold the FIFO head for at most five
minutes by default (FLUXUS_END_USER_EVENT_TURN_MAX_MS, minimum one minute); this clock starts when the run becomes
the active turn, not when it was submitted. If it exceeds that limit, Confluye releases the FIFO and marks the run
failed, with error: "The end user turn exceeded its time limit and was stopped." The run has no separate error-code
field. The failed run remains available through polling and, for product-started runs, delegation lookup. A
conversation that parks on an approval or a timer releases the order: the next run starts and sees the parked message
as pending. Erasure cancels queued event and conversational runs alike.
Poll or cancel a run
GET /api/v1/end-users/runs/{id} and DELETE /api/v1/end-users/runs/{id}, both with the X-End-User-Id header.
A run is visible only when its ID, the key that started it, and its end user all match, and that end user is not
being erased; otherwise the answer is 404. Runs of an erased end user are never visible, even after the same
external ID starts a new end user.
{
"run": {
"id": "run_1",
"workflowId": "wf_abc",
"status": "succeeded",
"startedAt": "…",
"finishedAt": "…",
"durationMs": 5120
},
"status": "succeeded",
"queued": false,
"active": false,
"output": { "answer": "…" },
"error": null,
"links": { "self": "/api/v1/end-users/runs/run_1", "cancel": "/api/v1/end-users/runs/run_1" },
"cancelMethod": "DELETE",
"pollAfterMs": null
}
Keep polling while active is true (queued, running, or waiting), after pollAfterMs. The response never
contains the workflow definition, steps, or logs. DELETE cancels a queued, running, or waiting run and returns
the same shape; cancelling a finished run answers 409, and cancelling an already cancelled run returns it unchanged.
An Agent, Claude Code or Codex CLI block marked as the end-user reply can expose replyStream: { "text": "…" }
while its run is active. It is a cumulative, provisional snapshot of assistant text received during generation,
not a completed reply. Agent blocks use their configured API model and provider, including OpenAI, Anthropic,
Google, Azure and OpenAI-compatible endpoints. Adapters without incremental output keep their completed-answer path.
Render it as text or safe Markdown with raw HTML disabled, and replace it with the canonical output or signed
reply when the run succeeds. A failed, cancelled or waiting run returns no draft. Reasoning, tool arguments,
tool results and Claude subagent messages are excluded. Claude Code and Agent text may describe work before a tool
call; it is not proof that a write succeeded. Codex publishes only its final-answer phase. Resolved credentials
are redacted across fragment boundaries. Agent runtime guardrails also redact provisional text and hold unfinished
words and spaced digit sequences until they can be checked. Policies requiring a whole-answer classifier,
structured output validation or denial of an entire sensitive answer retain complete-answer validation before
publishing; streaming does not bypass that policy or call a classifier per token.
Reading progress never starts another model call. The snapshot uses the
same credential, workflow allowlist, end-user identity, private cache policy and erasure boundary as status polling.
Every response of these routes, including 400, 401, 403, and 404 answers, is Cache-Control: no-store with
Vary: Authorization, X-End-User-Id, Accept.
For live delivery over one continuous HTTP response, send Accept: text/event-stream to the same
GET /api/v1/end-users/runs/{id} route, with the same gateway key and X-End-User-Id header. The default
response remains JSON. The stream emits event: snapshot with the JSON status projection in data:
event: snapshot
data: {"run":{"id":"run_123","workflowId":"wf_123","status":"running"},"status":"running","active":true,"replyStream":{"text":"Bonjour"}}
event: snapshot
data: {"run":{"id":"run_123","workflowId":"wf_123","status":"running"},"status":"running","active":true,"replyStream":{"text":"Bonjour Québec"}}
Reply updates are pushed after the worker commits redacted progress, rather than discovered by a browser polling
timer. Slow consumers may receive coalesced cumulative snapshots. The first event contains the current state,
including on reconnect; the final event contains the canonical outcome and closes the stream. Authorization,
the current end-user generation and workflow access are rechecked during the subscription. Revocation or
erasure closes it. Comment frames keep the connection alive; periodic checks also recover terminal outcomes.
Disconnecting closes only the read subscription: it does not cancel, retry or start the run. Reconnect with the
same authorized GET; never repeat execution to recover a dropped stream. If live transport is unavailable,
the opt-in request can return 503; the existing JSON status endpoint remains the recovery path. Live replies
use the same model invocation and do not add an inference request for each fragment.
Website login required
When a block needs the customer's website login and it is not usable, the run ends failed without retries and the
status response carries:
{ "connectionRequired": { "reason": "expired", "siteKey": "centris" } }
reason | Meaning | New login link? |
|---|---|---|
missing | The customer never signed in to this site | Yes |
expired | The saved login needs the customer to sign in again | Yes |
rejected | The website rejected the saved login | Yes |
revoked | The previous saved login was disconnected | Yes, with a fresh login and save |
grant_missing | A workspace Admin or Owner has not authorized this block for the site | No |
For missing, expired, rejected, and revoked, your product can request a new link automatically when the
customer asks for something requiring that website. Keep the original request, send the link, and wait for the
customer to sign in and save access before sending the turn again. The previous login stays unusable; issuing a
link never restores it. grant_missing requires authorization from a workspace Admin or Owner.
Re-run
POST /api/v1/runs/{id}/rerun with the X-End-User-Id header re-runs one of the key's runs for the same end user.
201 returns the safe outcome projection plus statusUrl, cancelUrl, and cancelMethod. A run of another key or end user answers 404; a
run whose end user was erased answers 409, and one whose end user is being erased answers 409 with
code: "erasing" and Retry-After.
Website login links
POST /api/v1/end-users/connect-requests
{ "endUser": { "externalId": "tg-12345" }, "siteKey": "centris" }
201 — { "requestId": "…", "connectUrl": "https://…", "expiresAt": "…" }. Send connectUrl to the customer; it
works once and expires after 15 minutes. It signs the customer in to that website only and never grants a Confluye
session. A new link cancels the customer's previous link for the same site. Responses are Cache-Control: no-store
and are never replayed from an idempotency cache.
Poll GET /api/v1/end-users/connect-requests/{requestId} with X-End-User-Id set to the same external ID.
200 returns only { "requestId": "…", "status": "pending" | "connected" | "expired" | "cancelled" }.
connected means the customer saved this request's login and it is still usable. A completed request whose
login was disconnected or replaced cannot continue the action. Requests outside the key owner's workspace,
for another customer, or without a live website grant on a workflow allowed by the key answer 404.
Responses are Cache-Control: no-store with Vary: Authorization, X-End-User-Id; they contain no login link,
browser state, or connection ID. Polling creates no customer or connection and does not consume the link.
Use a durable Wait between polls and a bounded deadline, such as 15 minutes. On connected, submit the retained
customer request once and poll that new run. On cancellation or expiry, stop and explain that sign-in was not
completed. The platform does not automatically retry the original failed run or reconnect a revoked workflow grant.
Read the stored login state
GET /api/v1/end-users/browser-connections?siteKey=centris with X-End-User-Id set to the customer's external ID
reports whether Confluye holds a saved login for that customer and site. 200 returns only
{ "siteKey": "centris", "status": "ready" } or
{ "siteKey": "centris", "status": "required", "reason": "missing" | "expired" | "rejected" | "revoked" }.
required is the moment to request a login link; revoked follows the same rule as a link request, which answers
409 when the owner closed the login for good.
This is the stored state, not a check against the website: a ready login can still be refused by the site when
a workflow uses it. ready also grants nothing; each workflow block keeps its own site grant, and a revoked grant
stops that block while the saved login stays ready. A customer your product has not named yet, or one that is
being erased or was erased, reads required / missing; the request never creates a customer. A site that does
not exist, or has no live grant on a workflow allowed by the key, answers 404 site_not_found, a missing
or malformed siteKey or X-End-User-Id answers 400. The response carries no connection ID, session,
cookie, profile, account text, login link or customer ID, and is Cache-Control: no-store with
Vary: Authorization, X-End-User-Id. It uses the read intent of the product gateway key and requires no new permission.
Read a routine document
GET /api/v1/end-users/runs/{id}/artifacts/{artifactId} retrieves a PDF or ZIP produced by a completed
registered website routine. Use the same product gateway credential that started the run and the same
X-End-User-Id. Take artifactId from the routine's artifacts metadata, not from a website URL.
200 returns the document bytes with Content-Type, Content-Length, and an attachment filename.
Each file is at most 25 MiB. Verify its size, MIME type and SHA-256 against the routine's declared metadata,
then apply your product's normal scanning and record permissions before showing it as ready.
Access lasts at most the execution receipt's 24-hour window and also requires the original saved connection,
active subject generation, owner authority and live block grant. Another key, customer, run or artifact, an
expired receipt, or withdrawn access returns 404 without provider identifiers. Authentication and scope
failures retain their normal 401/403 responses. Every response is Cache-Control: no-store and varies by
Authorization and X-End-User-Id. Login state, cookies and worker file paths are never returned.
Mailboxes and calendars
Your product can have an end user (for example a real-estate agent) connect their own Gmail or Outlook mailbox and
calendar with a one-use link, and receive what is new in signed mail.batch deliveries. The person needs no Confluye
account. All of these routes use a product gateway key with the run permission, answer Cache-Control: no-store, and
reach only the key's own workspace. Your product needs a product connection with a
registered mailBatchUrl: that is where the events go.
Other mail providers (IMAP and SMTP)
A product can also connect a hosted business mailbox, including cPanel mail. Using the Outlook application does not mean Microsoft hosts the account. Ask the mailbox provider for its server settings; do not redirect an IMAP account to Microsoft sign-in.
From your authenticated product backend, call POST /api/v1/end-users/mail-connections/imap with the gateway's run key:
{
"endUser": { "externalId": "agent-123" },
"returnUrl": "https://crm.example.com/settings/mail",
"credentials": {
"address": "agent@example.com",
"imap": {
"host": "mail.example.com",
"port": 993,
"user": "agent@example.com",
"password": "mailbox-or-app-password"
}
}
}
The returnUrl must belong to the registered product receiver; it selects the receiver when a workspace has several.
productConnectionId can select it explicitly. The answer is 201 { connection: ... }, with provider imap, capabilities
["mail"], and no credentials. Existing Gmail/Microsoft one-use links and their defaults are unchanged.
An optional connectionId reconnects only that same end user's account and receiver. Authentication happens before
activation; the subject and target are checked again under locks before credentials are sealed. A changed password
can renew the same mailbox at the five-connection limit. Erasure or disconnection winning the lock prevents revival.
Sending requires separate user consent. Only after the person explicitly enables sending, add
credentials.smtp with host, user, password and port (465 for implicit TLS or 587 for mandatory STARTTLS).
This grants ["mail", "send"]. Reconnecting an account that already has send access requires its SMTP settings too.
Each message still needs the exact authenticated draft approval and the same durable send key described below.
Unknown SMTP outcomes are never automatically resent. SMTP acceptance does not prove delivery, and this connection
does not write a copy to the Sent folder; whether the provider archives outgoing messages depends on that provider.
IMAP uses read-only mailbox access over TLS on port 993. SMTP authentication never falls back to plaintext.
Certificates must match the original server hostname. Only public destinations are accepted; private networks,
metadata services and DNS answers containing internal addresses are blocked even when operator allowlists exist.
Passwords are encrypted in the end user's mailbox connection; products must never put them in agent inputs, logs,
conversation history or their own database. On failure, return the friendly equivalent of mail_auth_failed,
mail_server_unavailable or the existing connection/limit errors; do not display raw server responses.
IMAP authentication and folder polling accept at most 1 MiB of received control traffic per connection; message reads
accept at most 96 MiB per connection. SMTP connections have a separate 1 MiB cumulative receive limit on the transport,
including the TLS handshake and traffic before and after STARTTLS. These bounds apply even when a server splits its
answer into many small responses.
The same signed batches, acknowledgement cursors, attachment reads and approved-send endpoint serve IMAP accounts.
Polling normally runs every two minutes. The initial snapshot takes at most the newest 250 messages in each of Inbox
and Sent from the last 90 days, in pages of 25 per folder. Folder/UIDVALIDITY/UID identities also bind the server and
login, so moving a mailbox cannot make old references read another message. Incremental pages contain at most 25 actual
messages per folder, even when UID numbers have large gaps. A server's definitive NO [NONEXISTENT] folder response
makes its queued references unavailable without blocking other folders. Other errors, including a plain NO without
that response code, remain retryable rather than being acknowledged as deletion. References fit the shared 1,024-character
limit, including encoded folder names. Sent folders whose names cannot fit are skipped; Inbox remains available.
A message over 16 MiB delivers bounded
metadata with bodyTruncated; attachment retrieval for that oversized message is unavailable. IMAP supplies no
calendar: connect Google or Microsoft separately for appointments. Disconnecting deletes the sealed IMAP/SMTP
credentials locally; there is no provider OAuth grant to withdraw.
Sending an approved email
For Gmail or Microsoft, sending requires a separate, explicit OAuth consent. Extend the existing connection with its connectionId and
"capabilities": ["send"]; this keeps its existing mail/calendar streams. The guest page explains the sending
permission before Google or Microsoft asks for it. The product must provide an authenticated review screen where
the person approves the exact recipients, subject and body. Do not expose your gateway key or a send-approval tool
to the model. An assistant may prepare a draft and link to that screen.
POST /api/v1/end-users/mail-send requires the product gateway key's run permission:
{
"endUser": { "externalId": "t_9f0c:u_42" },
"connectionId": "cmg1x…",
"idempotencyKey": "draft:approved-version-1",
"to": ["client@example.com"],
"subject": "Your viewing",
"text": "Your viewing is confirmed for Friday at 14:30."
}
The request accepts up to 25 recipients in each of to, optional cc and optional bcc, a plain-text body up to
100,000 characters, and optional threadId, inReplyTo and references. Header line breaks are refused. It uses
only this active end user's active mailbox credential. A read-only connection returns 403 send_permission_required;
a mailbox outside this workspace/end user or one no longer active returns 404 mailbox_not_found.
Optional attachments accepts up to ten {name, mimeType, contentBase64} objects, with at most 3,000,000 decoded bytes combined. Base64 must be canonical; names reject path separators and header controls, and MIME types must be plain type/subtype values. Your product scans files and checks current permission and the exact reviewed snapshot before calling. Bytes are included in the idempotency hash, so changing an attachment under the same approved key returns 409 idempotency_conflict. Receipts never retain attachment content.
Outlook also checks a conservative 4,000,000-byte limit on the complete JSON message, including encoded files, body and recipients. A message can therefore exceed the simple-send limit before reaching the combined decoded-file cap; reduce its size and create a new reviewed draft. Known provider 4xx refusals return failed; interrupted requests and uncertain 5xx outcomes remain in_doubt. Large-file upload sessions are not supported by this endpoint; see Microsoft's attachment limits.
The response contains requestId, status, providerMessageId, providerThreadId, failureCode and replayed.
accepted means the provider accepted submission, not delivery to the recipient. failed is a known refusal.
in_doubt means the provider outcome could not be confirmed: check the sent folder and never silently resend.
sending means an identical approved submission is still in progress. Preserve the exact approved payload and
idempotency key when explicitly resuming after an interrupted product request. An identical key/payload replays its
receipt; changing the payload with the same key returns 409 idempotency_conflict. Claims are stored before the
provider call so an unknown outcome is never submitted again. Receipts retain hashes and provider IDs, not the
recipient list or message body, and are deleted with the end user or mailbox.
Retrieve an email attachment
POST /api/v1/end-users/mail-attachments uses the gateway's run permission and the active end user's existing mailbox read grant:
Outlook lists downloadable file attachments. Attached Outlook items and cloud references are skipped so they do not hide files in the same message; the returned indexes refer only to those files.
{
"endUser": { "externalId": "t_9f0c:u_42" },
"connectionId": "cmg1x…",
"messageId": "provider-message-id",
"index": 0
}
Omit index to list up to 25 {index, name, mimeType, size} descriptors. Supply a returned index to retrieve attachment with contentBase64, sha256, text, textStatus and textTruncated. The download limit is 10 MiB per file and extracted text is limited to 20,000 characters. PDF, DOCX and PPTX extraction runs in a bounded isolated worker; UTF-8 text and JSON are read directly. Other formats remain downloadable with textStatus: "unsupported". Files are not executed or persisted by this endpoint.
Your product derives the provider message id and owner from its authenticated records, checks current message/record visibility, scans returned bytes before exposing any file or extracted text, and rechecks visibility after retrieval. Do not accept mailbox identity from a model. Names and extracted text are untrusted data, not instructions. An inaccessible mailbox returns 404 mailbox_not_found; an unavailable index returns 404 attachment_not_found. Provider errors return a generic attachment-unavailable result without credentials or process output.
Request a link
POST /api/v1/end-users/mail-connect-requests
{
"endUser": { "externalId": "t_9f0c:u_42" },
"provider": "gmail",
"capabilities": ["mail", "calendar"],
"productConnectionId": "pc_1",
"connectionId": "cmg1x…",
"returnUrl": "https://crm.example.com/settings/mail"
}
provider is gmail or outlook. capabilities defaults to both mail and calendar; the consent asks only for
read-only access to those streams. To enable user-approved email sending, explicitly request "send" as an additional capability; it is never included by default. To let Confluye write the calendar events your product schedules, explicitly request "calendar_write" (see Calendar write permission); it is never included by default either. productConnectionId names the connection that receives the events; leave it out
when the workspace has only one connection with a mailBatchUrl. When several connections receive mail, an omitted
productConnectionId is resolved by the exact origin of returnUrl: exactly one receiver in the workspace must match.
This lets production and Review use separate receivers without changing each other's mail links. Without a return
URL, or when several receivers share its origin, name the connection explicitly (400, product_connection_required).
An unregistered return origin is rejected (400, return_url_invalid).
returnUrl is optional: an https URL on the product connection's origin to send the browser back to when the consent
ends. No other field is accepted.
connectionId is optional and names one of the end user's mailboxes (for that provider and product connection, in
state active or needs_reauth) that the link adds the listed capabilities to, or reconnects. The consent then also
asks for what that mailbox already does, so it keeps every stream it has and the cursors you confirmed: a mailbox with
mail that gets a link with "capabilities": ["calendar"] ends with both. Without connectionId, capabilities is
the whole set the mailbox has after the consent: connecting an account that is already connected with fewer
capabilities stops the streams that are no longer covered, deletes their cursors and cancels the mail.batch deliveries
still waiting for them: a batch of a stream the mailbox no longer has is never sent. To add the calendar to a connected
mailbox, name it with connectionId or list both capabilities. A link that names a connectionId can only complete
that mailbox: the person must sign in with the same account that is connected. If they choose another account at the
provider, nothing is connected and the mailbox is left as it was. Confluye attempts to revoke the Google grant; Microsoft
has no revocation call, so its credential is discarded and the person must remove the Confluye app grant from their
Microsoft account if they want provider-side access removed. The request ends failed with reason account_mismatch
(or target_connection_unavailable when the mailbox was
disconnected after the link was issued). The 5-mailbox limit counts mailboxes, so a link that names a connectionId
works even when the end user already holds 5: it repairs or extends one of them and adds none. For a new-mailbox
link without connectionId, the limit is checked again when the connection is stored: if the end user reached it
after the link was issued, nothing is connected and the
request ends failed with reason connection_limit_reached. Confluye attempts Google grant revocation; for Microsoft
it discards the credential, and the person must remove the Confluye app grant from their Microsoft account if they want
provider-side access removed.
201 — { "requestId": "…", "connectUrl": "https://…#…", "expiresAt": "…" }. Send the person's browser to
connectUrl from inside your own signed-in session and never show the URL. The link works once and expires after
15 minutes (the consent screen then has 10 minutes more); it is bound to the browser that opens it. A new link for the
same end user withdraws the older ones, even one whose consent has already come back and is still being completed: that
one connects nothing and ends failed with reason link_superseded. Responses are never replayed from an
idempotency cache.
What the person sees and what is asked of them:
- The page needs no Confluye sign-in. It says what the link requests (the email, the calendar or both, as listed in
capabilities), and only the link's own token can read that. A button sends the browser to Google or Microsoft, which asks the person to sign in and approve read-only access: Gmailgmail.readonlyand Google Calendarcalendar.events.readonly(plusopenidandemailto learn the address), or MicrosoftMail.ReadandCalendars.Read(plusoffline_accessandUser.Read). An explicitly requestedsendcapability additionally asks for Gmailgmail.sendor MicrosoftMail.Send; it does not permit changing or deleting existing messages. No sending scope is requested by default. An explicitly requestedcalendar_writecapability additionally asks for Googlecalendar.events.ownedor MicrosoftCalendars.ReadWrite, and the page then says that Confluye creates, updates and removes the events your product schedules instead of saying it cannot change anything. - The link is secured three ways: a cookie of the browser that opened it, the OAuth
state, and PKCE. If the consent is finished in another browser, or the link is opened a second time, nothing is connected and the person is told to ask for a new link. The page sendsReferrer-Policy: no-referrer. - When the consent ends, the browser goes to your
returnUrlwithconfluyeMailConnect(connected,cancelledorfailed) andrequestIdadded, or to a Confluye page that says the same. Nothing else (no connection ID, address, code or error text) travels in a URL.
Errors: 409 mail_batch_url_not_registered (no connection with a batch URL), 409 connection_limit_reached (an end
user holds at most 5 mailboxes), 409 end_user_unavailable, 400 return_url_invalid, 404
product_connection_not_found, 404 not_found (connectionId is not an active or needs_reauth mailbox of this
end user for that provider and product connection), 403 calendar_write_unavailable when the link asks Google for
calendar_write before that permission is released for this deployment, and 503 provider_unavailable when this deployment has no Confluye OAuth app for the
provider, or the provider's permissions are not yet approved for it.
This is checked before a link is issued, using the requested capabilities (including those of a mailbox being reconnected)
and the same Google release policy as the consent page. A refused replacement leaves the previous link intact.
The consent page checks again, so a provider or policy change after issuance can still stop a previously issued link.
If the externalId is new, the end user is created together with the link, in one transaction. A request that is refused
with any error above creates no end user and does not count against your key's per-minute limit on new end users, so you
can correct it and retry. A new end user holds no mailbox, so naming a connectionId for one answers 404 not_found.
GET /api/v1/end-users/mail-connect-requests?id={requestId} answers
{ "requestId": "…", "status": "pending" | "connected" | "cancelled" | "failed" | "expired", "connectionId": "…", "reason": "…" }.
connectionId is present once connected; reason (scope_missing, exchange_failed, account_unusable,
account_mismatch, link_superseded, link_expired, connection_limit_reached…) when failed. A link is checked
again when its connection is stored, after the provider exchange: one that a newer link replaced meanwhile
(link_superseded), or whose consent window (10 minutes) ended meanwhile (link_expired), connects nothing and the
grant it obtained is withdrawn at the provider (Google; Microsoft has no such call). Ask the person to use the newest
link, or request a new one.
Calendar write permission
calendar_write lets Confluye write the events your product schedules into the person's own calendar (Google:
calendars they own, calendar.events.owned; Microsoft: Calendars.ReadWrite). Requesting it does not write anything
by itself. To add it to a connected mailbox, name the mailbox with connectionId and send
"capabilities": ["calendar_write"]: the capabilities are merged and the person consents again to everything the
mailbox does plus calendar writing, so its streams and cursors are kept. Calendar writing is not a stream: it adds no
mailboxRef.
- A broader permission counts for a narrower one: Microsoft's
Calendars.ReadWritealone covers bothcalendarandcalendar_write, and Google'scalendar.eventscovers both.Calendars.Readorcalendar.events.readonlyalone never grantscalendar_write. - If the person leaves calendar writing out at Google, the mailbox still connects, without
calendar_write. Read the connection'scapabilitiesbefore relying on it. Any other missing permission fails the request withscope_missing. - Google's
calendar.events.ownedstill needs Google's verification. Until this deployment's administrator records that approval, a Google link withcalendar_writeanswers403calendar_write_unavailable; Outlook links are not affected.
Write the events your product schedules
With calendar_write on a Google or Microsoft connection, your backend writes the events it schedules (a showing, an
appointment) into the person's own calendar. Both requests require the product gateway key's run permission and name
the event by your own event key. Never send a provider event ID: Confluye resolves the key against its own receipt
for this connection, so a request can only reach events Confluye wrote for your product through it.
POST /api/v1/end-users/calendar-events creates the event for the key, or updates the one already written:
{
"endUser": { "externalId": "t_9f0c:u_42" },
"connectionId": "cmg1x…",
"eventKey": "showing:7f3c",
"idempotencyKey": "showing:7f3c:v4",
"event": {
"title": "Visite — 12 rue Saint-Denis",
"start": "2026-11-03T19:00:00Z",
"end": "2026-11-03T20:00:00Z",
"timeZone": "America/Toronto",
"location": "12 rue Saint-Denis, Montréal",
"description": "https://crm.example.com/showings/7f3c"
}
}
startandendare instants with an offset (Zor-05:00); the event lasts more than zero and at most 14 days. Milliseconds are preserved, including for events shorter than one second.timeZoneis an IANA name. Google receives it as is; Outlook receives its Windows name (America/Toronto→Eastern Standard Time). When Windows' rules for that name would give the event another offset (a region that changed its rules), either endpoint falls in a repeated local hour, or the zone has no Windows name in Confluye's table, the Outlook event is written inUTC: it still shows at the right time in the person's own zone.- Each request sends the whole event: an omitted
locationordescriptionis cleared. - Limits:
eventKeyis 1 to 256 andidempotencyKey1 to 128 printable ASCII characters without spaces (!to~);titleis one line of 1 to 1,024 characters;locationholds at most 1,024 characters anddescriptionat most 8,192;timeZoneat most 64;connectionIdat most 128 andendUser.externalIdat most 256. The whole request body is at most 32 KB. Anything else answers400 invalid_input. - The event has no attendees, so nobody is invited or notified (Google:
sendUpdates=none), and it uses the person's default reminders. Confluye does not change attendees the person adds afterwards. If an Outlook event has attendees, automatic updates and deletion return409 event_has_attendeesto avoid meeting updates or cancellations. Manage that event in Outlook, or remove its attendees there before retrying. This also applies when attendees are added while the request is running: Confluye checks the event again before retrying a conflicting change. - Every event carries a marker of your product (a private extended property at Google, a single-value extended property
at Microsoft). Before an update or a delete, Confluye reads the event; an event without your marker is never changed
and the request returns
409 event_not_owned. Both providers receive the checked event version with the mutation. If another calendar client changes that version, Confluye rereads the marker before retrying, up to three attempts; repeated conflicts return503 calendar_write_busy. - If the person deleted the event, the next update creates it again and answers
recreated: truewith the newproviderEventIdand the nextgeneration. A previously written key you deleted and write again also answersrecreated: true, under the next generation. Deleting an unseen key does not make its first write a recreation.
DELETE /api/v1/end-users/calendar-events takes the same endUser, connectionId, eventKey and idempotencyKey
(no event). An event that is already gone, or a key that never had an event, answers deleted. If an earlier request
for the key failed after the provider may have created the event (its answer was lost), the delete first looks for that
event and removes it. When the provider cannot say whether it exists, the delete fails with a retryable error instead
of answering deleted. The intent is committed before contacting the provider, so this reconciliation also covers a
process interruption or a failed result commit. A successful delete of a never-seen key is remembered and replays like
any other completed request.
At Outlook, reconciliation follows every page of marker matches, including older cancelled events, and uses a live
event for an update. The search is limited to 10 pages and 25 matches. If either limit prevents a complete search,
the request returns 503 calendar_write_busy, keeps the unresolved intent, and does not report deletion or create
another event. Retry the same request; if the limit persists, contact support to reconcile the matching events.
Both answer 200 with eventKey, status (written or deleted), providerEventId, generation, recreated and
replayed. Replaying a recreation preserves recreated: true. Repeating a completed request with the same key, idempotency key and body replays its answer without asking
the provider again (replayed: true); the same idempotency key with another body returns 409 idempotency_conflict.
A request that failed can be repeated as is: provider writes are idempotent (Google gets an event ID derived from your
key, Microsoft a transactionId and a search by your marker), so a repeat never creates a second event. Requests
through one connection run one after the other.
Confluye remembers only the most recent idempotency key of each event key. Once you sent a newer request for a key,
never resend an older one: it is not recognized as a repeat, so it runs as a new request and writes the older content
back (replayed: false). Send the requests for one key in order, and drop a superseded request instead of retrying it.
| Status | code | Retry? | What to do |
|---|---|---|---|
400 | invalid_input | No | Fix the request; unknown fields (such as a provider event ID) are refused |
403 | gateway_required | No | Call with a product gateway key |
403 | calendar_write_permission_required | No | Request calendar_write with a new link; the connection does not grant it |
404 | connection_not_found | No | The connection is not this end user's, not active, or in another workspace |
409 | connection_reauth_required | Not until the person returns | The provider refused the grant (withdrawn or expired): ask the person to reconnect, then send the request again |
409 | event_has_attendees | Not until attendees are removed | The Outlook event has attendees; manage it in Outlook or remove attendees there before retrying |
409 | event_not_owned | No | The event does not carry your marker; Confluye does not touch it |
409 | idempotency_conflict | No | Use a new idempotency key for a changed request |
429 | rate_limited | Yes, after the delay | Retry the same request after Retry-After (also in retryAfterSeconds) |
502 | provider_refused | Not as is | The provider refused this request; repeating it unchanged fails again. Change the request, or stop for this key |
503 | provider_unavailable | Yes | Temporary; retry the same request later (Retry-After when the provider gave one) |
503 | calendar_write_busy | Yes | Temporary (the connection was busy or the request ran out of time); retry the same request |
A 500 without a code is a fault on Confluye's side, not a busy connection.
The receipt keeps your event key, the provider event ID, the generation (and that of a create whose outcome is not known yet), the last idempotency key, a payload hash and a short failure code. It never keeps the title, place, times, description or a provider's error message, and it is deleted with the connection or the end user. Erasing the end user does not remove events already in their calendar.
The calendar read is unchanged: a written event comes back in the calendar stream as an ordinary
calendar.event.upserted, with no attendees and without your key or marker.
Read and disconnect a mailbox
POST /api/v1/end-users/mail-connections with { "endUser": { "externalId": "…" } } lists the end user's connections
(an unknown end user has none and is not created); GET …/mail-connections?id={id} returns one, in any state. A
connection is:
{
"id": "cmg1x…",
"provider": "gmail",
"address": "agent@example.com",
"capabilities": ["mail", "calendar"],
"status": "active",
"connectedAt": "…",
"lastSyncedAt": "…",
"lastError": null,
"mailboxes": { "mail": "mbx_cmg1x…:mail", "calendar": "mbx_cmg1x…:calendar" }
}
status is active, needs_reauth (the person withdrew the grant, or the provider refused the credential: send a new
link; connecting the same account again keeps the cursors you confirmed) or revoked. Only a refusal that the provider
words as a missing permission moves a mailbox to needs_reauth; another refusal (for example a Microsoft 365 tenant
policy, an unlicensed mailbox or a conditional access block) keeps it active with lastError provider_error,
because a new link would not fix it. address is the account's
address as the provider verified it; ingest a mailbox only when it matches the person's verified email or a confirmed
alias. lastError is a short code (needs_reauth, provider_unavailable, rate_limited, provider_timeout,
provider_error), never a provider message. mailboxes names the mailboxRef of each stream, as it appears in
deliveries. Nothing here is a token, a message or an event.
DELETE …/mail-connections?id={id} disconnects a mailbox, for example when you remove a member: Confluye deletes the
address and the cursors, drops the batches not yet delivered and withdraws the grant at Google (Microsoft has no such
call; the person can also remove the app in their account). It answers the connection as revoked and is safe to
repeat. A mail.batch request that is already on its way to your product when the DELETE arrives is allowed to finish
first (the DELETE waits for it, for up to the request's timeout); no request for the mailbox leaves after the
DELETE has answered. If Google does not confirm the withdrawal (it is unreachable or answers an error), the answer is the same and
nothing more is delivered for the mailbox, but Confluye keeps the sealed credential it needs and tries again with a
growing delay for up to 7 days, and every repeat of the DELETE tries again; once Google confirms, or the 7 days have
passed, the credential is deleted. A connection of another workspace answers 404.
What Confluye delivers
Each connection has up to two streams, mail and calendar, each with its own mailboxRef and cursor. New events
arrive as mail.batch deliveries with the same signature, confirmation
by ack and redelivery from the same cursor described there. The cursor is cur_<n>: data.cursor.from (the batch is
under the envelope's data) is null for the first batch of a stream and then the cursor you last confirmed.
| Event type | When |
|---|---|
email.inbound | A message arrived in the inbox. |
email.sent | A message was sent (Gmail SENT, Outlook Sent Items). Mail to oneself yields both events. |
calendar.event.upserted | An event was created or changed. Recurring events come as their series. |
calendar.event.deleted | An event was deleted or cancelled. |
data.events[].id is <provider>:<kind>[.b]:<providerId> (gmail:in:18c5a, outlook:out:AAMk…, gmail:cal:3k2d…). For mail, it identifies the immutable provider message in that direction: deduplicate retries by provider, kind and provider ID, ignoring the optional .b backfill marker so the same message is not ingested twice after a cursor reset. Inbox and sent events for a message have different kinds and are separate events. Calendar IDs identify the provider's event resource, not an immutable change occurrence: use the ID (ignoring .b) as the calendar record's key and apply every delivered upsert or deletion to that record. Calendar changes reuse the ID and carry the event's current state, so store each delivery in cursor order and let the latest delivery update the record; do not discard later changes as duplicate events. Confluye does not provide a separate calendar change-occurrence ID. An email event's data has mailbox (provider, address), backfill, providerMessageId,
threadId, internetMessageId, inReplyTo, references, from, to, cc, bcc ({ address, name }), subject,
receivedAt or sentAt, snippet, labels, attachments (name, type and size only, Gmail) or hasAttachments
(Outlook), textBody, htmlBody, bodyTruncated and metadataTruncated. Bodies and metadata are bounded to keep each
delivery within its size limit; bodyTruncated marks shortened message bodies (a message too large for Confluye to read
whole arrives with its headers, no body and bodyTruncated: true; Gmail text or HTML stored separately
by the provider also arrives with bodyTruncated: true, and is not listed as a file attachment unless it has an attachment
filename or disposition). Parsing keeps at most 10 recipients or attachments per list, scans at most 128 headers
per MIME part and 256 MIME parts, and bounds recipient-header scans to 8,192 characters. Discarded metadata is never
materialized into a full retained list. If unvisited MIME parts might carry a body, bodyTruncated is also raised.
metadataTruncated marks shortened strings or
lists in event metadata. A calendar event's data has mailbox, backfill,
providerEventId, status, summary, description, descriptionTruncated, location, start, end, organizer,
attendees, recurrence, recurringEventId, htmlLink and updatedAt; a deletion has mailbox, backfill and
providerEventId. Calendar events are bounded like messages: the description shares the batch's body budget (at most
16 KiB, descriptionTruncated marks a shortened one; for Outlook it is the event's body as text, and an event too large
for Confluye to read whole arrives with Graph's short preview and descriptionTruncated: true), and long strings and lists (attendees, recurrence rules) are cut
and flagged metadataTruncated, so a calendar event with thousands of attendees never keeps a batch from being
delivered. The type, mailbox, provider IDs and status of an event are never shortened.
- Initial load. The first read of a mailbox is a bounded load of the last 90 days, newest first: at most 250 messages
of the inbox and 250 of sent mail, in batches of at most five events. Outlook must still scan the initial delta’s ID/timestamp metadata to obtain a baseline cursor. That scan can take several bounded polling passes; the 250-message limit bounds delivered backfill content, not all metadata scanned. Its events are marked
backfill: true(and carry.bin their ID). The first sync of a calendar covers 30 days in the past through 180 days in the future for both providers, at most 500 events, also markedbackfill. Google returns recurring events as a series. Both providers renew their bounded window before its end; Google cursors created before the window-end field was stored are rebaselined once. After the initial sync, both providers follow changes within the window. If the provider no longer holds the history Confluye was following (Gmail history expired, a Google sync token invalidated, a Microsoft Graph delta link dropped with HTTP 410), the bounded load starts again: your product may receive events it already has, markedbackfill. For messages, deduplicate by provider, direction and provider message ID while ignoring.b; for calendar events, upsert the current resource state by provider, kind and provider event ID, including deletions. - Order and pace. A stream has at most one unconfirmed batch of up to five events; the next one is read only after you confirm it. A mailbox is read about every two minutes, and the rest of an initial load follows each confirmation within seconds. Conversation and run-result callbacks have their own, higher-priority delivery lane, so a slow mailbox never holds them back. The lanes run independently: the order in which a mail batch and a run result reach you is not guaranteed.
- Bodies. Confluye reads each message from the provider when it sends the batch and never stores it. The bodies of a
batch share a budget so a delivery stays under 1 MiB: a body over its share is cut and the event says
bodyTruncated: true. Attachments are not delivered. - No models. No workflow run, Agent block or model reads these messages. Outlook reports every change to a message (read, flagged): only mail received since the last read counts as new.
Remembered facts (memory)
A durable fact belongs to the agent's partition and to whom it is about: the end user's own preference
(agent), one of the customers the end user serves (customer), or unclassified. Facts written before this scope
existed, and facts Confluye could not place with confidence, are unclassified: they stay visible here and in exports,
and no run ever receives them as an agent preference or as a customer fact. See End users and
memory for how a run reads them.
Select the customer of a run
Send productContext.selectedCustomerExternalId on execute, from your authenticated
backend, after your own authorization of that customer. It must be one of productContext.subjectExternalIds, which stays
the set of people the run touches (the set an erasure of a subject walks) and is never read as the selection, even when
it holds one ID. A value that is malformed or not in subjectExternalIds answers 400 with
code: "product_context_selected_customer_invalid". The idempotency fingerprint covers it, and a queued run keeps it
until it runs. No message, input field or model output can set or change it. A run without it, such as a request from an
older client or one with no customer chosen, reads only the end user's own preferences and the work in progress kept for
no customer, and reads and learns no customer-scoped facts. The recent conversation and its rolling summaries are
agent-wide and not filtered per customer, so they can still contain customer information. A fact about a customer that Confluye cannot place becomes a proposed, unclassified fact. So does a fact whose
source sentence names a person ("Mi cliente Bruno prefiere…"), because the selection is an opaque ID and nothing proves
the name is that customer, whatever its capitalization ("mi cliente bruno…" is a name as much as "Mi cliente Bruno…"); classify it
through the memory route with the verified customer reference.
The memory route
/api/v1/end-users/memory needs a memory key and the end user in the X-End-User-Id header, like the other routes
that take no body ID. It never creates an end user, never names another one and returns no resource IDs: it reaches only
the memory resources connected to the key's allowedWorkflowIds, in the end user's current generation. A fact outside
those resources, of another end user, of another customer than the customer you named, or from an older generation
answers like a missing fact. Responses are no-store. Reads and changes are audited with internal IDs only, never a value,
a name or a customer reference.
Requests made for a delegated run (X-End-User-Run-Id). A request your backend makes on behalf of a run, such as
an MCP or service memory call a run triggered, can name that run in the optional X-End-User-Run-Id header on GET,
PATCH and DELETE. Send the run ID your backend verified, never one a model or a customer supplied. Omit the header for
a person's own, freshly signed-in view: that request keeps the key-granted facts of the end user, unchanged. The header is
a constraint, never authority; it adds no permission and picks no source or membership. When it is present Confluye
requires that the run belongs to the key's workspace and to one of its allowedWorkflowIds, was started for exactly the
end user in X-End-User-Id at its current generation (a run frozen before an erasure cannot reach the end user created
afterwards under the same ID), and has not finished. It then limits the facts to the intersection of the key's grants with
the memory resources that run was admitted with and that are still connected: a resource connected after the run was
admitted never joins it, and a disconnect ends access. Any failure answers the same 404 memory_end_user_not_found
with no data, no audit event and no write; so does a header that is empty, malformed or longer than 100 characters
(A-Z a-z 0-9 _ -), and there is no fallback to the key's grants. A request that succeeded bound to a run answers
X-End-User-Run-Bound: 1; a backend that sent the header should treat a success without it as unbound and discard it.
The header is never taken from the query or the body. JSON request and response fields do not change.
List — GET /api/v1/end-users/memory?customer={reference}&scope={agent|customer|unclassified}&limit={1-100}&cursor={opaque}.
With no scope it returns the end user's own facts, the facts about the customer you named (if any) and the
unclassified ones; never another customer's. scope=customer needs customer. Facts are active or proposed, newest
first, limit defaults to 50:
{
"facts": [
{
"id": "…",
"resource": "Real-estate customers",
"key": "home_city",
"value": "Laval",
"status": "active",
"provenance": "client_message",
"scope": { "kind": "customer", "customer": "crm:person:42" },
"revision": 3,
"protected": false,
"updatedAt": "…"
}
],
"nextCursor": null
}
provenance is client_message, agent_reply, agent_tool or owner (a person wrote or edited it). protected is
true once a person wrote or edited the fact: automatic extraction never replaces it.
Correct or classify — PATCH. To classify an unclassified fact, name the customer only inside classify. The
top-level customer is omitted, because an unclassified fact takes none:
{
"factId": "…",
"expectedRevision": 3,
"value": "Montréal",
"classify": { "scope": "customer", "customer": "crm:person:42" }
}
To correct a fact that is already scoped to a customer, send the top-level customer with exactly that customer and no
classify (a classified fact cannot be classified again):
{
"factId": "…",
"expectedRevision": 3,
"value": "Montréal",
"customer": "crm:person:42"
}
Send value, classify or both. expectedRevision is the revision you read. classify moves an unclassified fact
to {"scope":"agent"} or {"scope":"customer","customer":"…"} and activates it; a fact that is already classified
answers 409 memory_already_classified (delete it instead). A fact whose scope is customer is reached only with
customer set to exactly its customer: leaving it out, or naming another, answers 404 memory_fact_not_found
before any revision is compared and returns no current fact, so a stale expectedRevision never reveals a value. A fact
in the agent or unclassified scope takes no top-level customer; naming one answers 404 memory_fact_not_found. Moving an unclassified fact to a customer names that
customer in classify, which your backend sends only for a customer it has authorized. Classifying keeps the fact's
original erasure tags and adds the customer (conservative): the fact is read only as the new customer's, but erasing a
person the source turn also named still removes it. A value that looks like a secret answers 422 memory_value_rejected; a value over 300 characters or blank
answers 400. A correction makes the fact protected and wins over any extraction that finishes later: that job can
only leave a proposed fact linked to yours. It answers { "fact": { … } } with the new revision.
Delete — DELETE with { "factId": "…", "expectedRevision": 3, "customer": "…" } answers { "deleted": true }.
customer is required for a customer fact and must be omitted for the others, as for a correction. A deleted fact
stays deleted: a memory job queued before the deletion (even one whose reply is recorded afterwards) writes nothing back
into that resource, neither the fact nor a reworded or reclassified version of it. A message sent after the deletion may
teach the fact again. Confluye keeps only IDs and a queue position for this, never the value, key or customer; they go
with the end user's erasure.
If expectedRevision is not the fact's current revision, nothing is written and the route answers 409 with
code: "memory_revision_conflict" and the current sanitized projection in fact, so the person sees the current value
before sending again. Other answers: 400 invalid request (memory_invalid_request), 404 unknown end user or refused run
(memory_end_user_not_found) or fact (memory_fact_not_found), 409 erasing while the end user is being erased, and
503 memory_busy while a subject erasure is running (wait and retry).
Erase an end user
POST /api/v1/end-users/erasures
{ "endUser": { "externalId": "tg-12345" } }
202 — the erasure started, or was already running or finished (repeating the request is safe):
{
"erasure": { "id": "eu_1", "status": "erasing" },
"links": { "self": "/api/v1/end-users/erasures?id=eu_1" },
"pollAfterMs": 2000
}
Poll GET /api/v1/end-users/erasures?id={id} until status is erased (pollAfterMs is then null). Erasure
cancels the customer's open runs and deletes their conversation, memory, work in progress, pending site notes, and
website logins in every workflow of the workspace. It disconnects and deletes the customer's connected mailboxes and
calendars (the grant is withdrawn at Google, best effort, and the credential is deleted from Confluye either way), their
pending mailbox links and cursors, and the mail.batch deliveries still waiting for them. A callback to your product that is being sent at that moment finishes first, so nothing about the customer arrives after the erasure completes. It also erases the runs, with
their Agent tool calls and events, that name the customer in productContext.subjectExternalIds, including historical workspace-key runs that already stored those fields before productContext became product-gateway-only. New workspace-key execution requests cannot set productContext. The erased runs keep their status and timing but lose their content, including
the productContext and the delegationRef and usageTag copied from it, so
GET /api/v1/runs?delegationRef= no longer finds them. From the moment it starts, a new run for the same ID answers
409 with Retry-After. Once it finishes, the same ID starts a new end user with no history. While the erasure waits for a website login to be deleted
at the browser provider, the erasure object includes pendingReason: "browser_revocation"; keep polling.
All erasure calls need a key with the erase permission.
Erase every end user of a tenant
A product that gives each of its tenants an ID prefix (t_<uuid>:u_<id>) erases a whole tenant in two calls. The prefix
must be exactly t_, a UUID and a colon, such as t_0b9e6f2a-7c4d-4f0e-9a61-3d5c8e1f2a47:; an empty, truncated or
non-UUID prefix, or one that is a full external ID, answers 400 with code: "invalid_prefix" and erases nothing.
1. Dry run. It counts the end users of the key's workspace whose external ID starts with the prefix, erases nothing, and returns a confirmation token that is valid for 15 minutes:
{ "endUserPrefix": "t_0b9e6f2a-7c4d-4f0e-9a61-3d5c8e1f2a47:", "dryRun": true }
{
"dryRun": true,
"endUserPrefix": "t_0b9e6f2a-7c4d-4f0e-9a61-3d5c8e1f2a47:",
"matched": 12,
"confirmationToken": "euc_…",
"confirmationExpiresAt": "…"
}
2. Confirm. Send the token. Without it, or with an unknown or expired token, nothing is erased:
{ "endUserPrefix": "t_0b9e6f2a-7c4d-4f0e-9a61-3d5c8e1f2a47:", "confirmationToken": "euc_…" }
202 — the erasure is running as a job:
{
"bulkErasure": {
"id": "…",
"endUserPrefix": "t_0b9e6f2a-7c4d-4f0e-9a61-3d5c8e1f2a47:",
"status": "running",
"matched": 12,
"progress": { "enrolled": 0, "erasing": 0, "erased": 0, "batches": 0 }
},
"links": { "self": "/api/v1/end-users/erasures?bulkId=…" },
"pollAfterMs": 2000
}
Poll GET /api/v1/end-users/erasures?bulkId={id} until status is completed. Confirmation checks that no matching
end user was created after the dry run, even if another previewed end user was erased in the meantime. If the matching
population gained an end user, the call answers 409 (code: "confirmation_stale") and you make a new dry run. A
smaller population is safe to confirm because every remaining end user was included in the preview. Repeating a
confirmation with the same token reports the same erasure. The job erases the end users in batches with a pause between them, and each end user is erased exactly
as above, with its own tombstone. End users already being erased are enrolled as well, so a single-user erasure
started earlier does not leave the tenant job waiting forever. A crash or restart resumes where it stopped. End users that do not match the prefix
are never touched. Stop sending runs for the tenant before you erase it: an end user that is not erased yet can still
run. An end user created with the prefix while the job is running is erased as well: the job reports completed only
after a final check, made while new end users of that tenant wait, finds none left. One created after completed is a
new end user.
Erase a subject
A subject is a person who is not an end user, such as a CRM contact that your runs name in
productContext.subjectExternalIds. POST /api/v1/end-users/erasures:
{ "subject": { "externalId": "t_0b9e6f2a-7c4d-4f0e-9a61-3d5c8e1f2a47:p_5d1c9a30" } }
202 — { "subjectErasure": { "id": "…", "status": "running" }, "links": { "self": "/api/v1/end-users/erasures?subjectId=…" }, "pollAfterMs": 2000 }.
The subject is never echoed in the answer. Poll GET /api/v1/end-users/erasures?subjectId={id} until status is
completed. Erasing a subject:
- cancels the runs of the workspace that name the subject and redacts their content, including steps, logs, Agent tool calls and events, and removes the subject from the run (another subject named on the same run stays);
- deletes the memory facts of any end user that are tagged with the subject or scoped to it as their customer. Fact
extraction tags a fact about a customer with that customer, and a fact it could not place with every subject of the
run that produced it; a fact a person classified to the customer is tagged too, so the facts an agent holds about a
CRM contact can be found. An agent's own preference, and a fact about another customer, extracted from a run that also
named the subject are independent of it and stay. They keep the
sourceTurnIdandsourceRunIdof the turn and run they came from (opaque IDs, never the content of the erased turn), so a later erasure that lists that run insourcecan still find the facts it applies to. Facts that a run naming the subject produced before tagging existed are deleted too. - deletes the conversation turns of those runs and work in progress last written by them or kept for that customer, and clears the rolling summaries of the affected agents' memory partitions. Other turns and facts stay; summaries can be rebuilt from retained turns. Erasure waits for active summary jobs and prevents an older job from restoring deleted content. A memory job queued before the erasure finds that its run no longer names the customer and writes nothing, so deleted facts and work in progress are not restored, and later exports no longer contain them.
It does not erase end users. Repeating the request while one is running reports it; asking again after it completed erases anything the subject was named on since.
Subject created by an Agent run: source
When an Agent creates a person through your own tools in a run that named nobody, no subjectExternalIds tag points to
that person, so the erasure above cannot find what that run remembered. Your backend knows which Confluye runs it
trusted to read, create or change the person (it records the run ID it verified for each call) and sends them as
source:
{
"subject": { "externalId": "t_0b9e6f2a-7c4d-4f0e-9a61-3d5c8e1f2a47:p_5d1c9a30" },
"source": { "version": 1, "runRefs": ["cm2x0r4k10001ab9z8q7p6o5n"], "delegationRefs": [] }
}
versionmust be1.runRefsare run IDs of your workspace (at most 100 per array and request, each 1-128 characters of letters, digits, or. _ : ~ -; repeats count once).delegationRefsis optional and only for data written before you recorded run IDs: theproductContext.delegationRefyou sent when you started those runs (the same limits). The block accepts no other field, and a model never supplies it. An emptyrunRefsstates that no run produced the data.- The answer carries
provenanceVersion: 1insubjectErasure, on the202and on everyGET. A provider that predatessourcerejects the unknown field with400, and an answer withoutprovenanceVersionwas not made by one that understood it: do not treat either as erased. Repeating a request returns the erasure that already took the same references, and a request with new references for the same subject while one is running is merged into it, so you may send your references in several requests; the erasure completes only after every one is erased. - For each reference Confluye erases what your workspace owns: the end user run itself (cancelled if open, content
redacted), the conversation turns it recorded, the work in progress it last wrote, and the facts it produced that are
not independent of the subject: facts scoped to the subject, and the ones that could not be placed (
unclassified, including those written before facts had a scope). Listing a run applies the erasure's existing subject scope to the facts from that run; it does not delete every fact the run produced. The Agent's own preferences and facts about other customers that the same run produced stay, and keep the run and turn IDs they came from. This works after the run itself was removed by retention, because the facts, turns and work records name the run, and it works for a run listed after an earlier ordinary erasure of the subject, because the facts that stayed kept those IDs. A reference that names nothing your workspace owns erases nothing and is not an error; another workspace's runs are never reached. The rolling summaries of the affected agents are cleared and rebuilt from the turns that remain. - Confluye also records a fence for each erased reference (IDs only): a memory job of that run, or of any run of the same end user accepted before the erasure, that finishes afterwards writes nothing, under the same key or another key, scope or classification, whether the run still exists or not. A message sent after the erasure may teach the Agent again.
- Product routine runs. The CRM starts its
client_intake_routinewith its gatewayrunkey as its own agent (endUser.externalIdof the agent),mode: "event", aproductContextand no message. That run belongs to the agent's active generation and records no conversation turn, so it is an ordinary end user run for erasure. The new client does not exist when it starts andsubjectExternalIdsis empty, so list the run inrunRefs(or itsdelegationRefindelegationRefs). Confluye then erases what it erases for any listed end user run, as described above: it cancels the run if it is open, redacts its stored content (the validated client in its input, output, steps and logs), erases the unclassified facts it produced and the work in progress it last wrote, clears the agent's rolling summaries and records the fence for the reference where the run was admitted to a memory resource. The agent's own preferences, other customers' facts and other turns stay. A run of your workspace that acts for no end user is accepted only as the legacy shape of that routine, from before this transport: no end user and no end user mode, started through the API by an API key, with a validatedproductContextcarrying the event{ "type": "crm.routine.invoked", "entry": "client_intake_routine" }and the storeddelegationRefandusageTagmatching the context's. Confluye checks the run's stored provenance, never the request, and for such a run it cancels it if it is open, waits for a step that is still executing, and redacts only that run's own stored content (input, output, steps, logs, effects, pending callbacks). It creates no end user, writes no fence and reads or deletes no memory, browser session or credential record. In both shapes the run row stays for history and billing, without its content or product references. - Reference eligibility. A
delegationRefsentry that names no run answers422withcode: "source_provenance_unverifiable"on an initial request; nothing is erased because the provider cannot verify where the subject's data went. A reference that resolves to a run Confluye cannot erase here (for example, a member or infrastructure run that does not name the subject) answers422withcode: "source_run_ineligible". ArunRefsentry naming no run owned by your workspace is accepted as an empty reference, including when retention has removed the run. A same-workspace run that resolves but is neither an end user run nor a verified product routine run answers422withcode: "source_run_ineligible"unless the lost-response rule below acknowledges it. A malformed block answers400withcode: "invalid_source". None of these answers repeats a reference or the subject. - Lost responses. Erasing a run removes the stored reference that proved it. Confluye therefore keeps a receipt for
each reference it accepts (a workspace-keyed digest of the reference and of the subject, never either one) for 90
days, and an identical request for the same subject in the same workspace is answered by the erasure that took it (still
running, or completed), without starting another. A receipt applies only to a reference accepted for the same subject
in the same workspace. References without a matching receipt follow the eligibility rules above; an unknown
runRefsentry is still an empty reference. Retries differ by reference type:runRefs. A retry that names no run owned by your workspace remains a successful empty reference, whether the run was erased, removed by retention, or never existed; a receipt is not required. If the run still resolves but is now ineligible, only a receipt for the same subject within 90 days acknowledges it. Without that receipt, or after it expires, the response issource_run_ineligible. If only independent Agent or other customers' facts remain with the run's provenance, a completed receipt acknowledges the retry without starting another erasure or advancing its memory fences. New run content, turns, work records or eligible facts still require erasure, even with a receipt.delegationRefs. If the run still resolves, ordinary eligibility checks apply and no unexpired receipt is required. If the run no longer resolves, a receipt for the same subject within 90 days acknowledges the lost response. If it is absent or expired, the response issource_provenance_unverifiable; this also applies to references that were accepted before receipts existed.
countsof a finished erasure stay numbers only (for examplesourceRefs,sourceFences,facts,conversationTurns); they never carry a reference, an ID or content.
Not covered, because Confluye cannot tell which subject they are about unless you list the run in source (above): facts
written without a tag from a run that did not name the subject, conversation turns from runs that did not name it, and
work in progress last written by such runs. Listing the run in source is the exception for these run-linked stores only.
Never covered, with or without source: admin-managed skills that mention the subject in free-form instructions. Skill
content is not run-linked and source has no skill cleanup, so an acknowledged erasure can still leave the subject's
information in a skill. Review and remove it separately, through the skill administration your workspace uses, before you
treat the subject as erased.
Customer-scoped work records are covered even if the producer run row has been removed. A retry after an earlier completed erasure discovers and removes records for that customer. Untagged run content requires a valid source reference; when no reference can establish its association, erase the affected end user to remove those residuals. The erasure also fences subject runs and fact extraction before it completes, and removes every revision of a tagged fact: the superseded revisions it replaced and the later ones that replaced it. Discovery and deletion resume from private saved work; no fact IDs are exposed in job progress. Replacing or deleting an already captured memory fact is unavailable until its erasure finishes. Your product must stop sending data about the subject once it asks to erase it.
Export an end user
POST /api/v1/end-users/exports with an export key returns data for one end user of the key's workspace.
The default response is a bounded snapshot, not a complete access-request archive when any category is truncated.
Use row pagination below to retrieve the older records. The external ID travels in the body, never in the URL:
{ "endUser": { "externalId": "tg-12345" } }
Paged record export
Start with { "endUser": { "externalId": "tg-12345" }, "page": { "size": 500 } }.
size is an integer from 1 through 1,000 and applies separately to turns, facts, runs, site notes, mailbox history and
per-customer work records (partitions[].workRecord.customers, positioned by after.workRecords; truncated.workRecords is
reported on paged exports only). A continuation without after.workRecords starts the work records from the newest.
The response keeps the existing export format and adds export.pagination.nextPage. Send that complete object as
page in the next request, keeping the same endUser.externalId, until nextPage is null. Its generation must still
match the active user; an erased/recreated user returns 409 export_generation_changed, so restart instead of joining
two generations. An invalid page returns 400.
Every page rechecks the gateway's export permission, workspace and active-user fence, and is audited without caching.
Positions select records by ID and carry no authority; another user's positions cannot select their data. A null
position marks a finished category. Pages can split copies of a turn across memory resources: merge by resource ID
and record ID rather than assuming each page has all copies. Summaries, work records, skills and browser metadata
may repeat between pages and should also be deduplicated by their identifiers. Pages follow record IDs, not recency.
This is a live paginated read, not a single database snapshot across requests; pause writes if a fixed-time archive is
needed, or repeat collection if data changes during the export. The default snapshot retains its newest-row and
whole-turn boundary rules.
200 — Cache-Control: no-store, and every call is audited:
{
"export": {
"format": "confluye.end-user-export.v1",
"generatedAt": "…",
"endUser": {
"id": "eu_1",
"externalId": "tg-12345",
"generation": 1,
"status": "active",
"synthetic": false,
"createdAt": "…"
},
"partitions": [
{
"memoryResource": { "id": "mr_1", "name": "Customers" },
"summary": "…",
"turns": [
{
"id": "…",
"runId": "…",
"recordSequence": 1,
"status": "recorded",
"message": "…",
"reply": "…",
"toolSummary": null,
"readable": true,
"createdAt": "…"
}
],
"facts": [
{
"id": "…",
"key": "preferred_form_language",
"value": "…",
"status": "active",
"provenance": "client_message",
"revision": 1,
"readable": true,
"ownerEditedAt": null,
"createdAt": "…",
"updatedAt": "…",
"entityKind": "customer",
"entityExternalId": "crm:person:42",
"sourceTurnId": "…",
"sourceRunId": "…"
}
],
"workRecord": {
"revision": 1,
"fields": { "request": "…" },
"proposal": null,
"ownerEditedAt": null,
"customers": [
{
"customer": "crm:person:42",
"revision": 1,
"fields": { "request": "…" },
"proposal": null,
"ownerEditedAt": null
}
]
}
}
],
"skills": [
{
"id": "…",
"name": "Territory",
"description": "Working area",
"instructions": "Focus on Laval.",
"enabled": true,
"revision": 1,
"readable": true,
"createdAt": "…",
"updatedAt": "…"
}
],
"browserConnections": [
{ "id": "…", "label": "…", "siteKey": "centris", "status": "ready", "createdAt": "…", "updatedAt": "…" }
],
"mailConnections": [
{
"id": "…",
"provider": "gmail",
"address": "agent@example.com",
"capabilities": ["mail", "calendar"],
"status": "active",
"connectedAt": "…",
"revokedAt": null
}
],
"siteNotes": [
{ "id": "…", "siteKey": "centris", "status": "approved", "provenance": "agent_tool", "createdAt": "…" }
],
"runs": [
{
"id": "…",
"workflowId": "…",
"status": "succeeded",
"startedAt": "…",
"finishedAt": "…",
"relation": "end_user"
}
],
"inventory": {
"included": [
{ "model": "EndUserConversationTurn", "section": "turns" },
{ "model": "EndUserConversationState", "section": "summary" },
{ "model": "EndUserFact", "section": "facts" },
{ "model": "EndUserWorkRecord", "section": "workRecord" },
{ "model": "EndUserSkill", "section": "skills" },
{ "model": "SiteNote", "section": "siteNotes" },
{ "model": "BrowserConnection", "section": "browserConnections" },
{ "model": "EndUserMailConnection", "section": "mailConnections" },
{ "model": "EndUser", "section": "endUser" },
{ "model": "WorkflowRun", "section": "runs" }
],
"omitted": [
{
"model": "WorkflowFileSubmission",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "WorkspaceAppInvocation",
"reason": "Internal App invocation receipt: App, version, workspace-member and run IDs plus creation time, not copied form values or output. The associated run is listed in `runs`."
},
{
"model": "EndUserFactTombstone",
"reason": "Fence of a deleted fact: IDs, a FIFO sequence and a timestamp. It holds no content of the end user, never the deleted value, key or customer reference."
},
{
"model": "ProductErasureSource",
"reason": "Scope of a source reference of a subject erasure in progress: a run reference, the end user and generation, and a resource ID. It holds no content of the end user."
},
{
"model": "EndUserTurnState",
"reason": "Internal ordering counters of the conversation (sequence numbers). They hold no content of the end user."
},
{
"model": "SecretProviderGrant",
"reason": "Owner-managed authorization of a workflow to use a credential reference (provider, item and field IDs) for this end user. It holds no content of the end user and never a credential value, which is not stored in Confluye."
},
{
"model": "ApiIdempotencyRecord",
"reason": "Receipts of the product's own requests; they repeat responses the product already received."
},
{
"model": "BrowserConnectRequest",
"reason": "Short-lived website login links; they hold a token digest and an expiry, no content of the end user."
},
{
"model": "ProductCallbackDelivery",
"reason": "Callbacks the product already received or will; run IDs, cursors and provider event IDs, never a body."
},
{
"model": "ProductMailCursor",
"reason": "A cursor the product itself confirmed; it names a mailbox position, not any content of the end user."
},
{
"model": "EndUserMailSendRequest",
"reason": "Outgoing request receipts: payload hashes and provider IDs only, never recipients or message content. The product retains the reviewed draft."
},
{
"model": "EndUserCalendarEventWrite",
"reason": "Receipts of calendar events the product wrote: its own event keys, provider event IDs and payload hashes, never event content. The product keeps the events it scheduled."
},
{
"model": "EndUserMailConnectRequest",
"reason": "Short-lived mailbox connection links; they hold token digests and an expiry, no content of the end user."
},
{
"model": "WorkflowStepRun",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "RunLog",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "WorkflowRunAttempt",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "WorkflowRunCheckpoint",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "WorkflowRunWaitToken",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "WorkflowRunEffect",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "WorkflowRunTrace",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "HumanTask",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "A2aTask",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "WorkflowSignalReceipt",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "UsageEvent",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "NotificationOutbox",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "AgentRunEvent",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "AgentToolCall",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "AgentStep",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "AgentRun",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "AgentGuardrailDecision",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "AgentRunFeedback",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "AgentShadowComparison",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "HumanTaskEvent",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "HumanTaskCandidate",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "BillingComputeSegment",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "BillingActiveComputeSegment",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
},
{
"model": "AiNodeUsage",
"reason": "Operational record of a run (payloads, steps, logs, tool calls, billing); the run is listed in `runs`."
}
]
},
"truncated": { "turns": false, "facts": false, "runs": false, "siteNotes": false, "mailConnections": false }
}
}
The export follows the same inventory of stored data that erasure uses: inventory lists every kind of data with
the section that carries it (included) or the reason it is left out (omitted). A section is the name of the
response field that holds the data: a field of export (endUser, skills, browserConnections, mailConnections,
siteNotes, runs) or a field of each element of partitions (summary, turns, facts, workRecord). It is the
same for every export, and the example above shows all of it. The export carries the memory partitions of
every memory resource (summary, turns, facts of every status including superseded history, and work in progress),
the end user's enabled and disabled skills with decrypted name, description and instructions,
website logins as metadata only (never the saved session), connected mailboxes and calendars as metadata only (provider,
address, capabilities, state and dates: never the credential, a cursor, a message or an event), the site notes the
customer's runs proposed as metadata only,
and the customer's runs and the runs that name them (relation is end_user or subject). The active generation's own runs include data.mode, submitted input, optional message, output and error, with credential fields and their registered string values redacted. This includes event runs, which record no conversation turn, and non-message fields submitted with conversational runs. Runs that only name this customer as a subject remain metadata-only; another person's payload is never included. Workspace-only snapshots, steps, logs and tool calls remain outside this product credential's scope. Do not assume all customer content is in conversation turns, or that this safe gateway export replaces a workspace operator's review of retained diagnostics for an access request. A memory value that the configured encryption keys cannot open has value: null and
readable: false. An unreadable skill keeps its ID, state, revision and dates, with name, description and
instructions set to null and readable: false; it does not prevent the other data from being exported.
Each bounded category (turns, facts, runs, site notes and mailbox-connection history) is cut at 5,000 rows, keeping the newest, and
truncated says which one was cut, including mailConnections for mailbox history; revoked mailbox connections count toward this export limit.
A turn that the customer's agent recorded in several memory resources is kept or cut whole: when the limit would fall
between its copies, the turn is left out of every resource, so a cut export can hold slightly fewer than 5,000 turns.
Each fact says whom it is about and where it came from: entityKind is agent, customer (with the customer's
reference in entityExternalId) or unclassified, and provenance, sourceTurnId and sourceRunId name its source
(both are null for a fact that did not come from a turn). A fact that stayed after a subject erasure because it was
independent of the subject keeps its original sourceTurnId and sourceRunId; they are opaque IDs, and the erased turn's
content is not exposed through them. workRecord is the work in progress of a run that selected
no customer; workRecord.customers holds the work in progress kept for each customer the end user's runs selected
(its revision is 0 and its fields empty when only customers have one). An export lists no fact or work record of
a customer already erased with a subject erasure.
An end user that does not exist in the key's workspace, or was erased, answers 404; one that is being erased answers
409 with code: "erasing".
Errors
| Status | When |
|---|---|
400 | Invalid JSON or fields; missing endUser.externalId; missing or blank message on a conversational end user run; invalid ID (code: "invalid_external_id"); endUser, message, or mode sent with another kind of key; dryRun or candidate on an end user run; missing or invalid X-End-User-Id; missing erasure id; an invalid tenant prefix (code: "invalid_prefix"); a tenant erasure without dryRun or confirmationToken (code: "confirmation_required"); missing or invalid permissions when creating a key; an invalid subject erasure source block (code: "invalid_source") |
401 | Missing, unknown, or revoked bearer token |
403 | Not a product gateway key ("A product gateway credential is required."); workflow not on the key's allowlist, end users disabled, or the creator lost workspace access ("A workspace API key is required."); the key lacks the permission the route needs (code: "gateway_permission_required", with the missing permission) |
404 | Run not found for this key and end user; site not granted to any allowed workflow (code: "site_not_found"); end user or erasure not found (code: "end_user_not_found"); a memory end user or fact not found (memory_end_user_not_found, memory_fact_not_found) |
409 | An Idempotency-Key reused with a different body, such as another message (code: "idempotency_replay_mismatch"); the end user is being erased (code: "erasing", Retry-After: 5); cancelling a finished run; re-running an erased customer's run; the customer is already signed in (code: "connection_ready"); a remembered fact changed since you read it (code: "memory_revision_conflict", with the current fact) or is already classified (memory_already_classified); a tenant erasure confirmation that is unknown or for another workspace or prefix (code: "confirmation_invalid"), expired (code: "confirmation_expired") or stale (code: "confirmation_stale") |
422 | A subject erasure source that cannot be proven: a delegation reference that names no run of the workspace and was not accepted before for this subject (code: "source_provenance_unverifiable"), or a reference that names a run that is neither an end user run nor a verified product routine run (code: "source_run_ineligible"); nothing is erased |
429 | Too many new end users for this key (code: "rate_limited", Retry-After: 60) |
503 | The workflow queue is not configured (end user runs are always queued); website connections are unavailable; erasure needs the queue while the customer has runs in flight, or extraction/compaction is still active (code: "erasure_unavailable"). Retry erasure after the memory jobs finish. The memory route answers memory_busy while a subject erasure is running. A subject erasure or a confirmed tenant-prefix erasure answers the same erasure_unavailable when the erasure queue is unavailable, before anything is recorded; a tenant-prefix dry run does not need the queue. |
