Confluye
Endpoints

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:

PermissionAllows
runTranscribe 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.
eraseErase one end user, every end user of a tenant prefix, or a subject, and poll those erasures.
exportExport the data stored for one end user.
memoryList, 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 except GET /api/v1/runs?delegationRef=... when it has run permission, 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 through GET /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.

ToolArguments and behavior
calendar.upsert-eventThe calendar create/update body below plus confirm: true; retains eventKey and idempotencyKey.
calendar.delete-eventThe calendar delete body below plus confirm: true.
workflows.executeworkflowId, endUser, the execution fields below, confirm: true and idempotencyKey. Supports productContext and callbackUrl under their existing contracts.
runs.getrunId and endUser; returns only this key's customer-run projection.
runs.cancelrunId, endUser and confirm: true.
runs.artifactrunId, endUser, artifactId, optional offset and maxBytes (at most 65,536). Returns base64 content, bytesRead, totalBytes, mimeType and nextOffset; repeat until nextOffset is null.
browser.statusendUser and siteKey; reads the saved-login status for a site granted to the key's workflows.
browser.connectendUser, siteKey and confirm: true; requests the customer's private one-use login link.
mail-connections.listendUser; 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

MethodPathDescription
POST/api/v1/workflows/{id}/executeRun 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}/rerunRe-run one of the key's runs for the same end user
POST/api/v1/end-users/connect-requestsOne-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-requestsOne-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-connectionsList an end user's connected mailboxes
POST/api/v1/end-users/mail-connections/imapVerify 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-sendSend an email approved in your product, with a durable idempotency key
POST/api/v1/end-users/mail-attachmentsList or retrieve a file from an active end user's mailbox
POST/api/v1/end-users/calendar-eventsCreate or update an event your product schedules, by your own event key (calendar_write)
DELETE/api/v1/end-users/calendar-eventsDelete 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/erasuresErase 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/memoryList the remembered facts of one end user (X-End-User-Id); requires memory
PATCH/api/v1/end-users/memoryCorrect a fact's value and/or classify it, with the revision you read; requires memory
DELETE/api/v1/end-users/memoryDelete one fact, with the revision you read; requires memory
POST/api/v1/end-users/exportsExport 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" }
}
FieldRequiredDescription
endUser.externalIdYesThe customer the run acts for. A new ID creates the end user.
messageFor conversationsThe customer's message, 1–32,000 characters and not blank. With the reply block's output it forms the turn recorded in memory.
modeNo"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.
inputNoThe workflow input, as for any execution. Pass the message here too when blocks read it from input.
productContextNoYour 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.
callbackUrlNoA 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" } }
reasonMeaningNew login link?
missingThe customer never signed in to this siteYes
expiredThe saved login needs the customer to sign in againYes
rejectedThe website rejected the saved loginYes
revokedThe previous saved login was disconnectedYes, with a fresh login and save
grant_missingA workspace Admin or Owner has not authorized this block for the siteNo

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.

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.

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: Gmail gmail.readonly and Google Calendar calendar.events.readonly (plus openid and email to learn the address), or Microsoft Mail.Read and Calendars.Read (plus offline_access and User.Read). An explicitly requested send capability additionally asks for Gmail gmail.send or Microsoft Mail.Send; it does not permit changing or deleting existing messages. No sending scope is requested by default. An explicitly requested calendar_write capability additionally asks for Google calendar.events.owned or Microsoft Calendars.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 sends Referrer-Policy: no-referrer.
  • When the consent ends, the browser goes to your returnUrl with confluyeMailConnect (connected, cancelled or failed) and requestId added, 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.ReadWrite alone covers both calendar and calendar_write, and Google's calendar.events covers both. Calendars.Read or calendar.events.readonly alone never grants calendar_write.
  • If the person leaves calendar writing out at Google, the mailbox still connects, without calendar_write. Read the connection's capabilities before relying on it. Any other missing permission fails the request with scope_missing.
  • Google's calendar.events.owned still needs Google's verification. Until this deployment's administrator records that approval, a Google link with calendar_write answers 403 calendar_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"
  }
}
  • start and end are instants with an offset (Z or -05:00); the event lasts more than zero and at most 14 days. Milliseconds are preserved, including for events shorter than one second. timeZone is 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 in UTC: it still shows at the right time in the person's own zone.
  • Each request sends the whole event: an omitted location or description is cleared.
  • Limits: eventKey is 1 to 256 and idempotencyKey 1 to 128 printable ASCII characters without spaces (! to ~); title is one line of 1 to 1,024 characters; location holds at most 1,024 characters and description at most 8,192; timeZone at most 64; connectionId at most 128 and endUser.externalId at most 256. The whole request body is at most 32 KB. Anything else answers 400 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 return 409 event_has_attendees to 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 return 503 calendar_write_busy.
  • If the person deleted the event, the next update creates it again and answers recreated: true with the new providerEventId and the next generation. A previously written key you deleted and write again also answers recreated: 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.

StatuscodeRetry?What to do
400invalid_inputNoFix the request; unknown fields (such as a provider event ID) are refused
403gateway_requiredNoCall with a product gateway key
403calendar_write_permission_requiredNoRequest calendar_write with a new link; the connection does not grant it
404connection_not_foundNoThe connection is not this end user's, not active, or in another workspace
409connection_reauth_requiredNot until the person returnsThe provider refused the grant (withdrawn or expired): ask the person to reconnect, then send the request again
409event_has_attendeesNot until attendees are removedThe Outlook event has attendees; manage it in Outlook or remove attendees there before retrying
409event_not_ownedNoThe event does not carry your marker; Confluye does not touch it
409idempotency_conflictNoUse a new idempotency key for a changed request
429rate_limitedYes, after the delayRetry the same request after Retry-After (also in retryAfterSeconds)
502provider_refusedNot as isThe provider refused this request; repeating it unchanged fails again. Change the request, or stop for this key
503provider_unavailableYesTemporary; retry the same request later (Retry-After when the provider gave one)
503calendar_write_busyYesTemporary (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 typeWhen
email.inboundA message arrived in the inbox.
email.sentA message was sent (Gmail SENT, Outlook Sent Items). Mail to oneself yields both events.
calendar.event.upsertedAn event was created or changed. Recurring events come as their series.
calendar.event.deletedAn 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 .b in 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 marked backfill. 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, marked backfill. 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 sourceTurnId and sourceRunId of the turn and run they came from (opaque IDs, never the content of the erased turn), so a later erasure that lists that run in source can 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": [] }
}
  • version must be 1. runRefs are run IDs of your workspace (at most 100 per array and request, each 1-128 characters of letters, digits, or . _ : ~ -; repeats count once). delegationRefs is optional and only for data written before you recorded run IDs: the productContext.delegationRef you sent when you started those runs (the same limits). The block accepts no other field, and a model never supplies it. An empty runRefs states that no run produced the data.
  • The answer carries provenanceVersion: 1 in subjectErasure, on the 202 and on every GET. A provider that predates source rejects the unknown field with 400, and an answer without provenanceVersion was 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_routine with its gateway run key as its own agent (endUser.externalId of the agent), mode: "event", a productContext and 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 and subjectExternalIds is empty, so list the run in runRefs (or its delegationRef in delegationRefs). 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 validated productContext carrying the event { "type": "crm.routine.invoked", "entry": "client_intake_routine" } and the stored delegationRef and usageTag matching 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 delegationRefs entry that names no run answers 422 with code: "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) answers 422 with code: "source_run_ineligible". A runRefs entry 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 answers 422 with code: "source_run_ineligible" unless the lost-response rule below acknowledges it. A malformed block answers 400 with code: "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 runRefs entry 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 is source_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 is source_provenance_unverifiable; this also applies to references that were accepted before receipts existed.
  • counts of a finished erasure stay numbers only (for example sourceRefs, 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

StatusWhen
400Invalid 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")
401Missing, unknown, or revoked bearer token
403Not 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)
404Run 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)
409An 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")
422A 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
429Too many new end users for this key (code: "rate_limited", Retry-After: 60)
503The 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.

Next steps