{"openapi":"3.1.0","info":{"title":"CodeSpar AgentGate API","version":"0.3.0","description":"Machine-readable contract for the CodeSpar API. Served without a credential\nat `/openapi.json` and `/v1/openapi.json`: the routes are protected, the\ndescription of them is not.\n\nSCOPE. This document covers what a credential of the kind described under\n`securitySchemes` can call, plus the public endpoints that issue one. Every\noperation below is reachable with an API key or an OAuth access token.\nOperator surfaces that a key cannot call are deliberately absent, and this\ndocument does not name them: a document served to anyone, by anyone, is the\nwrong place to enumerate a surface on the grounds that the reader cannot\nreach it. `check:openapi-coverage` walks the route tree for those families\nand fails the build if one appears here — as a path, or in prose.\n\nCOVERAGE IS PARTIAL AND MEASURED. Request bodies and queries here are the\nsame Zod schemas the routes validate with. Response schemas are hand-written\nand are NOT checked against handler output — treat a response shape as\ndocumentation, not as a contract, until that check exists. The client routes\nthis document omits are named, one per line, in a ledger\n`check:openapi-coverage` holds in CI, so the gap can shrink and cannot\nsilently grow. It is a large gap today.\n\nWHAT IS DESCRIBED END TO END. The OAuth 2.1 flow `securitySchemes` points\nat, and one complete agent loop: list servers, open a session on their ids,\nrecord a tool call, finalize it, read the session's calls back, close the\nsession. Policy rules, wallets and triggers are described as individual\noperations, not as a runnable path — the routes that connect a server, run a\ntool server-side and read a payment's status are in the ledger, so an\nintegration that needs those still needs the prose reference below."},"servers":[{"url":"https://api.codespar.dev","description":"CodeSpar API. The `csk_test_` / `csk_live_` key prefix picks the environment, not the host."}],"security":[{"bearerAuth":[]}],"externalDocs":{"url":"https://docs.codespar.dev/docs/api","description":"Prose reference, including the surfaces this spec does not yet describe."},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"An API key, sent as `Authorization: Bearer csk_live_… ` or `csk_test_…` (41 characters; the prefix picks the environment, not the host). An OAuth 2.1 access token from the flow advertised at `/.well-known/oauth-authorization-server` is accepted on the same header. A key is bound to one project; a service credential may target another with the `x-codespar-project` header."}},"schemas":{"Policy":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"type":"string","enum":["allow","deny","budget","rate-limit","time-window","approval-required"]},"config":{"type":"object","additionalProperties":{}},"agents":{"type":"array","items":{"type":"string"}},"tools":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"order":{"type":"number"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["id","name","type","config","agents","tools","enabled","order","createdAt","updatedAt"]},"PolicyEvaluation":{"type":"object","properties":{"id":{"type":"string"},"timestamp":{"type":"string","format":"date-time"},"agentId":{"type":"string"},"toolName":{"type":"string"},"ruleName":{"type":["string","null"]},"ruleId":{"type":["string","null"]},"decision":{"type":"string","enum":["allow","deny"]},"denialReason":{"type":["string","null"]}},"required":["id","timestamp","agentId","toolName","ruleName","ruleId","decision","denialReason"]},"PolicyCreate":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":128},"type":{"type":"string","enum":["allow","deny","budget","rate-limit","time-window","approval-required"]},"agents":{"type":"array","items":{"type":"string","minLength":1},"default":[]},"tools":{"type":"array","items":{"type":"string","minLength":1,"maxLength":200},"default":[]},"config":{},"enabled":{"type":"boolean","default":true}},"required":["name","type"]},"PolicyUpdate":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":128},"type":{"type":"string","enum":["allow","deny","budget","rate-limit","time-window","approval-required"]},"agents":{"type":"array","items":{"type":"string","minLength":1}},"tools":{"type":"array","items":{"type":"string","minLength":1,"maxLength":200}},"config":{},"enabled":{"type":"boolean"}}},"PolicyReorder":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string","minLength":1},"minItems":1,"maxItems":1000}},"required":["ids"]},"WalletBalance":{"type":"object","properties":{"wallet_id":{"type":"string"},"currency":{"type":"string","enum":["BRL","USD","MXN","COP","ARS","USDC","BRLA"]},"balance_minor":{"type":"string","description":"bigint as string"},"available_minor":{"type":"string","description":"bigint as string"},"updated_at":{"type":"string","format":"date-time"}},"required":["wallet_id","currency","balance_minor","available_minor","updated_at"]},"Wallet":{"type":"object","properties":{"id":{"type":"string"},"org_id":{"type":"string"},"project_id":{"type":"string"},"agent_id":{"type":["string","null"]},"display_name":{"type":"string"},"status":{"type":"string","enum":["active","frozen","closed"]},"created_at":{"type":"string","format":"date-time"},"closed_at":{"type":["string","null"],"format":"date-time"},"metadata":{"type":"object","additionalProperties":{}},"balances":{"type":"array","items":{"$ref":"#/components/schemas/WalletBalance"}}},"required":["id","org_id","project_id","agent_id","display_name","status","created_at","closed_at","metadata"]},"WalletLedgerEntry":{"type":"object","properties":{"id":{"type":"string","description":"bigserial as string"},"wallet_id":{"type":"string"},"org_id":{"type":"string"},"currency":{"type":"string","enum":["BRL","USD","MXN","COP","ARS","USDC","BRLA"]},"amount_minor":{"type":"string","description":"bigint signed minor units"},"kind":{"type":"string","enum":["fund","hold","release","debit","reconcile","reverse","fee"]},"mandate_id":{"type":["string","null"]},"attempt_id":{"type":["string","null"]},"external_ref":{"type":["string","null"]},"reconciled_at":{"type":["string","null"],"format":"date-time"},"posted_at":{"type":"string","format":"date-time"},"metadata":{"type":"object","additionalProperties":{}}},"required":["id","wallet_id","org_id","currency","amount_minor","kind","mandate_id","attempt_id","external_ref","reconciled_at","posted_at","metadata"]},"WalletFundingSource":{"type":"object","properties":{"wallet_id":{"type":"string"},"connection_id":{"type":"string"},"currency":{"type":"string","enum":["BRL","USD","MXN","COP","ARS","USDC","BRLA"]},"enabled":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"},"metadata":{"type":"object","additionalProperties":{}}},"required":["wallet_id","connection_id","currency","enabled","created_at","metadata"]},"WalletReconAnomaly":{"type":"object","properties":{"id":{"type":"string"},"wallet_id":{"type":"string"},"org_id":{"type":"string"},"kind":{"type":"string","enum":["debit_without_receipt","receipt_without_debit"]},"ledger_entry_id":{"type":["string","null"]},"external_ref":{"type":["string","null"]},"amount_minor":{"type":["string","null"]},"currency":{"type":["string","null"],"enum":["BRL","USD","MXN","COP","ARS","USDC","BRLA"]},"detected_at":{"type":"string","format":"date-time"},"status":{"type":"string","enum":["open","resolved","dismissed"]},"resolved_at":{"type":["string","null"],"format":"date-time"},"resolution_note":{"type":["string","null"]},"metadata":{"type":"object","additionalProperties":{}}},"required":["id","wallet_id","org_id","kind","ledger_entry_id","external_ref","amount_minor","currency","detected_at","status","resolved_at","resolution_note","metadata"]},"WalletCreate":{"type":"object","properties":{"display_name":{"type":"string","minLength":1,"maxLength":120},"currency":{"type":"string","enum":["BRL","USD","MXN","COP","ARS","USDC","BRLA"]},"agent_id":{"type":["string","null"],"minLength":1},"metadata":{"type":"object","additionalProperties":{}}},"required":["display_name","currency"]},"WalletExecute":{"type":"object","properties":{"amount":{"type":"number","exclusiveMinimum":0},"currency":{"type":"string","enum":["BRL","USD","MXN","COP","ARS","USDC","BRLA"]},"target_currency":{"type":"string","enum":["USD","EUR","BRL","MXN"]},"preferred_method":{"type":"string","minLength":1},"recipient":{"type":"string","minLength":1,"maxLength":256},"description":{"type":"string","minLength":1,"maxLength":512},"mandate_id":{"type":"string","minLength":1},"purpose":{"type":"string","minLength":1,"maxLength":64},"attempt_id":{"type":"string","minLength":1,"maxLength":128},"metadata":{"type":"object","additionalProperties":{}}},"required":["amount","currency","recipient","description","mandate_id"]},"WalletLedgerPost":{"type":"object","properties":{"wallet_id":{"type":"string","minLength":1},"org_id":{"type":"string","minLength":1},"currency":{"type":"string","enum":["BRL","USD","MXN","COP","ARS","USDC","BRLA"]},"amount_minor":{"type":"string","pattern":"^d+$"},"kind":{"type":"string","enum":["fund","hold","release","debit","reconcile","reverse","fee"]},"mandate_id":{"type":["string","null"],"minLength":1},"attempt_id":{"type":["string","null"],"minLength":1},"external_ref":{"type":["string","null"],"minLength":1},"metadata":{"type":"object","additionalProperties":{},"default":{}}},"required":["wallet_id","org_id","currency","amount_minor","kind","mandate_id","attempt_id","external_ref"]},"WalletFundingSourceBind":{"type":"object","properties":{"connection_id":{"type":"string","minLength":1},"currency":{"type":"string","enum":["BRL","USD","MXN","COP","ARS","USDC","BRLA"]},"metadata":{"type":"object","additionalProperties":{}}},"required":["connection_id","currency"]},"WalletAnomalyResolve":{"type":"object","properties":{"status":{"type":"string","enum":["resolved","dismissed"]},"note":{"type":"string","minLength":1,"maxLength":512}},"required":["status"]},"GatewayPaymentResult":{"type":"object","properties":{"requestId":{"type":"string"},"status":{"type":"string","enum":["completed","denied","requires-approval","failed"]},"audit":{"type":"array","items":{"type":"object","properties":{"timestamp":{"type":"string","format":"date-time"},"step":{"type":"string"},"status":{"type":"string","enum":["pass","fail","skip"]},"detail":{"type":"string"}},"required":["timestamp","step","status","detail"]}},"wallet":{"type":"object","properties":{"holdId":{"type":["string","null"]},"debitId":{"type":["string","null"]},"releaseId":{"type":["string","null"]},"insufficientFunds":{"type":"boolean"}},"required":["holdId","debitId","releaseId","insufficientFunds"]}},"required":["requestId","status","audit"]},"Session":{"type":"object","properties":{"id":{"type":"string","description":"`ses_`-prefixed"},"org_id":{"type":"string"},"project_id":{"type":"string"},"user_id":{"type":"string"},"servers":{"type":"array","items":{"type":"string"}},"status":{"type":"string","enum":["active","closed","error"]},"created_at":{"type":"string","format":"date-time"},"closed_at":{"type":["string","null"],"format":"date-time"}},"required":["id","org_id","project_id","user_id","servers","status","created_at"]},"ToolCall":{"type":"object","properties":{"id":{"type":"string","description":"`tc_`-prefixed: the bigserial with a `tc_` prefix prepended"},"session_id":{"type":"string"},"server_id":{"type":"string"},"tool_name":{"type":"string"},"status":{"type":"string","enum":["running","success","error"]},"duration_ms":{"type":["integer","null"]},"error_code":{"type":["string","null"]},"input":{},"output":{},"called_at":{"type":"string","format":"date-time"},"routing":{},"failover_trail":{}},"required":["id","session_id","server_id","tool_name","status","duration_ms","error_code","called_at"]},"Trigger":{"type":"object","properties":{"id":{"type":"string"},"org_id":{"type":"string"},"project_id":{"type":["string","null"],"description":"nullable during the 0014→0015 rollout"},"name":{"type":"string"},"event":{"type":"string"},"server_id":{"type":["string","null"]},"webhook_url":{"type":"string"},"status":{"type":"string"},"total_runs":{"type":"integer"},"last_run_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"signing_enabled":{"type":"boolean","description":"Whether a signing secret exists. The secret itself is never read back — the serializer drops the vault reference and reports only this."}},"required":["id","org_id","project_id","name","event","server_id","webhook_url","status","total_runs","last_run_at","created_at","signing_enabled"]},"TriggerCreated":{"allOf":[{"$ref":"#/components/schemas/Trigger"},{"type":"object","properties":{"secret":{"type":"string","description":"The webhook signing secret, in plaintext, shown EXACTLY ONCE. Later reads expose only `signing_enabled`. Lost secrets are replaced via POST /v1/triggers/{id}/rotate-secret."}},"required":["secret"]}]},"Whoami":{"type":"object","properties":{"user":{"type":"object","properties":{"email":{"type":"string"},"name":{"type":"string"}},"required":["email"],"description":"Display only. An API key is scoped to an org and a project, not to a user; this is a representative org member (owner first) for a 'logged in as' line, and it is absent when no member has an email."},"organization":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":["string","null"]}},"required":["id","name"]},"project":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":["string","null"]}},"required":["id","name"]},"key":{"type":"object","properties":{"id":{"type":"string"},"environment":{"type":"string"},"scopes":{"type":"array","items":{"type":"string"}}},"required":["scopes"]}},"required":["organization","project","key"]},"AgentRevokeLimits":{"type":"object","properties":{"stops":{"type":"array","items":{"type":"string"}},"does_not_stop":{"type":"string"},"issue":{"type":"string"}},"required":["stops","does_not_stop","issue"],"description":"What this verb reaches and what it does not. Served with every 200 so the limit travels with the act."},"ServerCatalogRow":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"pkg":{"type":"string"},"category":{"type":"string"},"country":{"type":"string"},"auth_type":{"type":"string"},"tools_count":{"type":"integer","minimum":0},"description":{"type":["string","null"]},"status":{"type":"string"},"provider_homepage":{"type":["string","null"]},"provider_logo_url":{"type":["string","null"]},"provider_logo_fallback_url":{"type":["string","null"]},"provider_docs_url":{"type":["string","null"]},"sandbox_available":{"type":"boolean"},"sandbox_url":{"type":["string","null"]},"subaccount_provisionable":{"type":"boolean"},"engine_provisionable":{"type":"boolean","description":"Has an engine provisioning descriptor AND is not quarantined."},"engine_status":{"type":"string","enum":["none","available","quarantined"],"description":"Carries the reason `engine_provisionable` is false."}},"required":["id","name","pkg","category","country","auth_type","tools_count","description","status","subaccount_provisionable","engine_provisionable","engine_status"]}},"parameters":{}},"paths":{"/.well-known/oauth-protected-resource":{"get":{"security":[],"summary":"Protected-resource metadata (RFC 9728)","description":"The document an MCP client fetches after a 401, from the `resource_metadata` the `WWW-Authenticate` header points at `resource` and `authorization_servers` are the same origin: this API is its own authorization server.\n\nHost-aware. The issuer is derived from the request's Host, so the copy served on a custom domain advertises that domain and not this document's `servers[0]` — a client that pasted one origin gets tokens minted for it (RFC 8707 resource indicators).","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"resource":{"type":"string"},"authorization_servers":{"type":"array","items":{"type":"string"}},"bearer_methods_supported":{"type":"array","items":{"type":"string"}},"scopes_supported":{"type":"array","items":{"type":"string"}}},"required":["resource","authorization_servers","bearer_methods_supported","scopes_supported"]}}}}}}},"/.well-known/oauth-authorization-server":{"get":{"security":[],"summary":"Authorization-server metadata (RFC 8414)","description":"The three endpoint URLs below, absolute under the issuer, plus what this server supports: `response_types_supported: [\"code\"]`, `grant_types_supported: [\"authorization_code\", \"refresh_token\"]`, `code_challenge_methods_supported: [\"S256\"]` and `token_endpoint_auth_methods_supported: [\"none\"]`.\n\nRead the last two together: this is a PUBLIC client with no secret, and PKCE S256 is mandatory rather than preferred. `plain` is never advertised and is refused at the authorize endpoint. Host-aware, like the document above.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"issuer":{"type":"string"},"authorization_endpoint":{"type":"string"},"token_endpoint":{"type":"string"},"registration_endpoint":{"type":"string"},"response_types_supported":{"type":"array","items":{"type":"string"}},"grant_types_supported":{"type":"array","items":{"type":"string"}},"code_challenge_methods_supported":{"type":"array","items":{"type":"string"}},"token_endpoint_auth_methods_supported":{"type":"array","items":{"type":"string"}},"scopes_supported":{"type":"array","items":{"type":"string"}}},"required":["issuer","authorization_endpoint","token_endpoint","registration_endpoint","response_types_supported","grant_types_supported","code_challenge_methods_supported","token_endpoint_auth_methods_supported","scopes_supported"]}}}}}}},"/oauth/register":{"post":{"security":[],"summary":"Register a client (RFC 7591 dynamic client registration)","description":"Open registration, on purpose: it is what lets an MCP client obtain a `client_id` with no pre-existing CodeSpar credential. `redirect_uris` must be absolute `https`, except loopback (`127.0.0.1`, `[::1]`, `localhost`), where `http` is allowed for a native client catching the redirect on a random port (OAuth 2.1 / RFC 8252 §7.3).\n\nA `client_id` is an identifier, not a credential. It carries no token, no tenant and no reach; every path from it to a token runs through the consent page, where a live API key has to be pasted, and the resulting token is bounded by that key's own scopes. Registration is not rate limited and not capped.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"redirect_uris":{"type":"array","items":{"type":"string"},"minItems":1,"description":"Absolute https URIs; http only for loopback hosts."},"client_name":{"type":"string","description":"Truncated to 256 characters. Rendered on the consent page."},"token_endpoint_auth_method":{"type":"string"},"grant_types":{"type":"array","items":{"type":"string"}},"response_types":{"type":"array","items":{"type":"string"}}},"required":["redirect_uris"]}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"type":"object","properties":{"client_id":{"type":"string"},"redirect_uris":{"type":"array","items":{"type":"string"}},"client_name":{"type":"string"},"token_endpoint_auth_method":{"type":"string","enum":["none"]},"grant_types":{"type":"array","items":{"type":"string"}},"response_types":{"type":"array","items":{"type":"string"}}},"required":["client_id","redirect_uris","token_endpoint_auth_method","grant_types","response_types"]}}}},"400":{"description":"`invalid_redirect_uri` when a URI is missing, relative, or http on a non-loopback host.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"error_description":{"type":"string"}},"required":["error"]}}}}}}},"/oauth/authorize":{"get":{"security":[],"summary":"Consent page (RFC 6749 §4.1.1 authorization request)","description":"Returns `text/html`, NOT JSON: this is the leg a human completes in a browser, so a generated client should open it rather than call it. `response_type=code`, a registered `client_id`, a `redirect_uri` matching one registered for that client exactly, and `code_challenge` with `code_challenge_method=S256` are all required.\n\nErrors are rendered as HTML with status 400 and are NOT redirected: per RFC 6749 §4.1.2.1 an invalid `client_id`/`redirect_uri` must not be redirected to, and this endpoint keeps that posture for every error so a human sees the reason on the page. A scope request this server cannot satisfy is refused here, before the form asking for a key is drawn.","parameters":[{"schema":{"type":"string","enum":["code"]},"required":true,"name":"response_type","in":"query"},{"schema":{"type":"string"},"required":true,"name":"client_id","in":"query"},{"schema":{"type":"string"},"required":true,"name":"redirect_uri","in":"query"},{"schema":{"type":"string"},"required":true,"name":"code_challenge","in":"query"},{"schema":{"type":"string","enum":["S256"]},"required":true,"name":"code_challenge_method","in":"query"},{"schema":{"type":"string"},"required":false,"name":"state","in":"query"},{"schema":{"type":"string","description":"Space-separated. Requesting more than the pasted key holds is refused."},"required":false,"description":"Space-separated. Requesting more than the pasted key holds is refused.","name":"scope","in":"query"}],"responses":{"200":{"description":"The consent page.","content":{"text/html":{"schema":{"type":"string"}}}},"400":{"description":"Rendered as HTML, never redirected.","content":{"text/html":{"schema":{"type":"string"}}}}}},"post":{"security":[],"summary":"Consent submit — exchanges a pasted API key for an authorization code","description":"`application/x-www-form-urlencoded`, submitted by the page above, not by a generated client. The form carries the same authorization-request parameters plus the API key the human pasted.\n\nOn success: 302 to `redirect_uri` with `code` and `state`. The code is single-use, short-lived, and bound to the tenant, the client, the redirect URI, the PKCE challenge AND the API key that authorized it — so revoking that key invalidates every token the code goes on to mint, and the token's scopes can never exceed the key's own. On refusal the page re-renders as HTML with 400.","requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"response_type":{"type":"string","enum":["code"]},"client_id":{"type":"string"},"redirect_uri":{"type":"string"},"code_challenge":{"type":"string"},"code_challenge_method":{"type":"string","enum":["S256"]},"state":{"type":"string"},"scope":{"type":"string"},"api_key":{"type":"string","description":"A live API key. Never stored, never returned."}},"required":["response_type","client_id","redirect_uri","code_challenge","code_challenge_method","api_key"]}}}},"responses":{"302":{"description":"Location: <redirect_uri>?code=…&state=… (RFC 6749 §4.1.2).","headers":{"Location":{"schema":{"type":"string"},"required":true}}},"400":{"description":"Rendered as HTML, never redirected.","content":{"text/html":{"schema":{"type":"string"}}}}}}},"/oauth/token":{"post":{"security":[],"summary":"Token endpoint — authorization_code and refresh_token grants","description":"`application/x-www-form-urlencoded`, unauthenticated (public client, `token_endpoint_auth_method: none`); the authorization code IS the authority. `grant_type=authorization_code` requires `code`, `code_verifier`, `redirect_uri` and `client_id`; `grant_type=refresh_token` requires `refresh_token`. Anything else is 400 `unsupported_grant_type`.\n\nRedemption is atomic and single-use even under a concurrent retry: a second redemption of the same code sees no row and gets 400 `invalid_grant`. The refresh grant ROTATES — the presented refresh token is revoked and a new one returned, so a client must persist the new value. `expires_in` is the access token's lifetime in seconds Responses carry `Cache-Control: no-store` (RFC 6749 §5.1).","requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"grant_type":{"type":"string","enum":["authorization_code","refresh_token"]},"code":{"type":"string"},"code_verifier":{"type":"string"},"redirect_uri":{"type":"string"},"client_id":{"type":"string"},"refresh_token":{"type":"string"}},"required":["grant_type"]}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"access_token":{"type":"string"},"token_type":{"type":"string","enum":["Bearer"]},"expires_in":{"type":"integer"},"refresh_token":{"type":"string"},"scope":{"type":"string"}},"required":["access_token","token_type","expires_in","refresh_token","scope"]}}}},"400":{"description":"`invalid_grant` (code invalid, expired, already used, bound to a different client or redirect_uri, PKCE verification failed, or carrying no recorded scope grant), `invalid_request`, or `unsupported_grant_type`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"error_description":{"type":"string"}},"required":["error"]}}}}}}},"/openapi.json":{"get":{"security":[],"summary":"This document","description":"Built once per boot from static Zod definitions, identical for every caller, and served with no credential — which is the whole point of ent#964. `/v1/openapi.json` returns the same bytes.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{}}}}}}}},"/v1/openapi.json":{"get":{"security":[],"summary":"This document, under the /v1 mount","description":"Identical to `/openapi.json`. Both mounts exist because a generator, a crawler or a reader will try either.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{}}}}}}}},"/v1/providers":{"get":{"summary":"List the MCP servers this project can attach to a session","description":"The catalog `POST /v1/sessions` draws its `servers` ids from. `total` is everything visible to this project, `filtered` is what survived the query parameters, and `servers` is the filtered page; the route does not paginate, so `filtered` and `servers.length` agree.\n\nVisibility is per project: another project's generated (`gen_`-prefixed) servers are absent, global catalog rows always present. Presence here is NOT permission to invoke — that is gated separately on a connected account, which is what `POST /v1/connect/start` establishes.","parameters":[{"schema":{"type":"string"},"required":false,"name":"category","in":"query"},{"schema":{"type":"string"},"required":false,"name":"country","in":"query"},{"schema":{"type":"string"},"required":false,"name":"q","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"total":{"type":"integer"},"filtered":{"type":"integer"},"servers":{"type":"array","items":{"$ref":"#/components/schemas/ServerCatalogRow"}}},"required":["total","filtered","servers"]}}}},"400":{"description":"Bad Request — the body or query did not match the schema.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body","invalid_query"]},"issues":{"type":"array","items":{},"description":"Zod issues, when the route reports them"}},"required":["error"]}}}}}}},"/v1/providers/{slug}":{"get":{"summary":"Read one provider","description":"The same enriched row the list returns, by slug — `{slug}` is the catalog primary key, the value `GET /v1/providers/{slug}/auth-schema` also takes. 404 for another project's generated (`gen_`-prefixed) provider: the list already hides it, so answering differently here would leak what the list conceals.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerCatalogRow"}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}}},"/v1/servers":{"get":{"deprecated":true,"summary":"List the MCP servers this project can attach to a session","externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979 — canonical path and alias window"},"description":"DEPRECATED alias of `GET /v1/providers` (ent#979), kept for two releases. Same handler, same required scope; switch the path and nothing else changes.\n\nThe catalog `POST /v1/sessions` draws its `servers` ids from. `total` is everything visible to this project, `filtered` is what survived the query parameters, and `servers` is the filtered page; the route does not paginate, so `filtered` and `servers.length` agree.\n\nVisibility is per project: another project's generated (`gen_`-prefixed) servers are absent, global catalog rows always present. Presence here is NOT permission to invoke — that is gated separately on a connected account, which is what `POST /v1/connect/start` establishes.","parameters":[{"schema":{"type":"string"},"required":false,"name":"category","in":"query"},{"schema":{"type":"string"},"required":false,"name":"country","in":"query"},{"schema":{"type":"string"},"required":false,"name":"q","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"total":{"type":"integer"},"filtered":{"type":"integer"},"servers":{"type":"array","items":{"$ref":"#/components/schemas/ServerCatalogRow"}}},"required":["total","filtered","servers"]}}}},"400":{"description":"Bad Request — the body or query did not match the schema.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body","invalid_query"]},"issues":{"type":"array","items":{},"description":"Zod issues, when the route reports them"}},"required":["error"]}}}}}}},"/v1/sessions":{"post":{"summary":"Open a session","description":"A session is the unit an agent's tool calls, policy evaluations and audit entries hang off. `servers` names the connected MCP servers it may reach (1-20), and every id must be one `GET /v1/servers` returns for this project — an id that is not gets 400 `unknown_servers` with the offenders listed, which is a different body from the schema `invalid_body` above it.\n\n`mocks` and `chaos` are accepted only on a project whose environment is `test`: a live-environment project gets 403 mocks_not_permitted, a payload over 64 KiB gets 413, and a malformed one gets 400 with RFC 6901 pointers pointing at the offending field.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"servers":{"type":"array","items":{"type":"string","minLength":1},"minItems":1,"maxItems":20},"user_id":{"type":"string","minLength":1,"maxLength":128},"mocks":{"type":"object","additionalProperties":{}},"chaos":{"type":"object","additionalProperties":{}}},"required":["servers"]}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Session"}}}},"400":{"description":"Bad Request — schema failure, or an unknown server id.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body"]},"issues":{"type":"array","items":{}}},"required":["error"]},{"type":"object","properties":{"error":{"type":"string","enum":["unknown_servers"]},"unknown":{"type":"array","items":{"type":"string"}}},"required":["error","unknown"]}]}}}},"403":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["mocks_not_permitted"]}},"required":["error"]}}}},"413":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["mocks_payload_too_large"]}},"required":["error"]}}}}}},"get":{"summary":"List sessions","description":"Newest first, keyed on (created_at, id) so a tie does not drop a page. `next_before` is the cursor for the following call and is null on the last page.","parameters":[{"schema":{"type":"integer","minimum":1,"maximum":100,"default":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string"},"required":false,"name":"before","in":"query"},{"schema":{"type":"string","enum":["active","closed","error"]},"required":false,"name":"status","in":"query"},{"schema":{"type":"string"},"required":false,"name":"user_id","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"sessions":{"type":"array","items":{"$ref":"#/components/schemas/Session"}},"next_before":{"type":["string","null"]}},"required":["sessions","next_before"]}}}},"400":{"description":"Bad Request — the body or query did not match the schema.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body","invalid_query"]},"issues":{"type":"array","items":{},"description":"Zod issues, when the route reports them"}},"required":["error"]}}}}}}},"/v1/sessions/{id}":{"get":{"summary":"Read one session","description":"The session plus `tool_calls_count`, the number of calls recorded against it. A session belonging to another project is indistinguishable from one that does not exist: both are 404.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`ses_`-prefixed"},"org_id":{"type":"string"},"project_id":{"type":"string"},"user_id":{"type":"string"},"servers":{"type":"array","items":{"type":"string"}},"status":{"type":"string","enum":["active","closed","error"]},"created_at":{"type":"string","format":"date-time"},"closed_at":{"type":["string","null"],"format":"date-time"},"tool_calls_count":{"type":"integer"}},"required":["id","org_id","project_id","user_id","servers","status","created_at","tool_calls_count"]}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}},"delete":{"summary":"Close a session","description":"Idempotent: closing an already-closed session returns the same body with the original `closed_at` rather than an error. Publishes `session.closed`, which a trigger can subscribe to. Returns the three fields below, not the whole session.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string","enum":["closed"]},"closed_at":{"type":["string","null"],"format":"date-time"}},"required":["id","status","closed_at"]}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}}},"/v1/sessions/{id}/tool-calls":{"post":{"summary":"Record a tool call the client executed","description":"This RECORDS a call; it does not execute one. Two ways to use it, and the difference is `status`. One shot: post a terminal `success`/`error` with `duration_ms` and `output`. Two step: post `running` with `input` as the call begins, then PATCH the outcome — which is what the SDK does and why `running` is the default.\n\nThe audit chain is appended exactly once per call, on the transition INTO a terminal status: the one-shot POST appends it here, the two-step path appends it on the PATCH. `server_id: \"mock\"` appends nothing (the mock store emits its own events). Chain entries written from this route are marked `client_sdk` — self-reported, distinct from server-observed execution.\n\n409 `session_not_active` when the session is closed. The returned `id` carries the `tc_` prefix and is what the PATCH path takes.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"server_id":{"type":"string","minLength":1},"tool_name":{"type":"string","minLength":1},"status":{"type":"string","enum":["running","success","error"],"default":"running"},"duration_ms":{"type":"integer","minimum":0},"error_code":{"type":"string"},"input":{},"output":{}},"required":["server_id","tool_name"]}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCall"}}}},"400":{"description":"Bad Request — the body or query did not match the schema.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body","invalid_query"]},"issues":{"type":"array","items":{},"description":"Zod issues, when the route reports them"}},"required":["error"]}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}},"409":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["session_not_active"]},"status":{"type":"string","enum":["closed","error"]}},"required":["error","status"]}}}}}},"get":{"summary":"List one session's tool calls","description":"Newest first, capped by `limit` and NOT paginated: there is no cursor here, so a session with more calls than `limit` (max 500) cannot be walked past the first page from this route. `GET /v1/tool-calls` is the paginated one, across the whole project.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"integer","minimum":1,"maximum":500,"default":100},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"tool_calls":{"type":"array","items":{"$ref":"#/components/schemas/ToolCall"}}},"required":["tool_calls"]}}}},"400":{"description":"Bad Request — the body or query did not match the schema.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body","invalid_query"]},"issues":{"type":"array","items":{},"description":"Zod issues, when the route reports them"}},"required":["error"]}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}}},"/v1/sessions/{id}/tool-calls/{tc_id}":{"patch":{"summary":"Finalize a recorded tool call","description":"A sparse patch: every field is optional and an omitted one keeps its current value (COALESCE), but an EMPTY body is refused — a patch that changes nothing is a caller bug, not a no-op.\n\nThe audit-chain entry is appended here only on the `running` -> terminal transition, and only once: patching an already-terminal call updates the row and appends nothing. `tc_id` is accepted with or without the `tc_` prefix; anything that is not a positive integer after stripping it is 404, not 400.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"`tc_`-prefixed"},"required":true,"description":"`tc_`-prefixed","name":"tc_id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["success","error"]},"duration_ms":{"type":"integer","minimum":0},"error_code":{"type":["string","null"]},"output":{}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCall"}}}},"400":{"description":"Bad Request — the body or query did not match the schema.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body","invalid_query"]},"issues":{"type":"array","items":{},"description":"Zod issues, when the route reports them"}},"required":["error"]}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}}},"/v1/tool-calls":{"get":{"summary":"List tool calls across the project's sessions","description":"Two response shapes on one operation: `count_only=true` returns `{ total }` and nothing else, so a counter does not have to page through every row Otherwise the page plus a composite `next_before` cursor of the form `<iso>:<id>`; a bare ISO timestamp is still accepted for older callers but pages unstably when timestamps collide.","parameters":[{"schema":{"type":"integer","minimum":1,"maximum":500,"default":100},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","format":"date-time"},"required":false,"name":"since","in":"query"},{"schema":{"type":"string"},"required":false,"name":"before","in":"query"},{"schema":{"type":"string","enum":["true","false"]},"required":false,"name":"count_only","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"tool_calls":{"type":"array","items":{"$ref":"#/components/schemas/ToolCall"}},"next_before":{"type":["string","null"]}},"required":["tool_calls","next_before"]},{"type":"object","properties":{"total":{"type":"integer"}},"required":["total"]}]}}}},"400":{"description":"Bad Request — the body or query did not match the schema.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body","invalid_query"]},"issues":{"type":"array","items":{},"description":"Zod issues, when the route reports them"}},"required":["error"]}}}}}}},"/v1/tool-calls/{id}":{"get":{"summary":"Read one tool call","description":"The same row the list returns, by id. `{id}` is the WIRE id (`tc_<n>`) the list emits, not the numeric column — a bare number is accepted too. Anything else is 404 rather than a database error.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCall"}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}}},"/v1/webhook-endpoints":{"post":{"summary":"Subscribe a webhook to an event","description":"The response is the ONLY time the signing secret is returned in plaintext. `webhook_url` is refused when it names a loopback, private or metadata host; DNS is re-checked at dispatch, so passing here is not a promise it will resolve publicly later.\n\n`event` is validated against a regex, NOT against a list of events the platform emits. A name that matches the shape but that nothing publishes is accepted with 201 and then never fires, with no signal to the subscriber.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":128},"event":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[a-z0-9_]+(\\.[a-z0-9_]+)*$"},"server_id":{"type":"string","minLength":1},"webhook_url":{"type":"string","maxLength":2048,"format":"uri"}},"required":["name","event","webhook_url"]}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCreated"}}}},"400":{"description":"Bad Request — the body or query did not match the schema.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body","invalid_query"]},"issues":{"type":"array","items":{},"description":"Zod issues, when the route reports them"}},"required":["error"]}}}}}},"get":{"summary":"List triggers","description":"Same cursor shape as /v1/sessions. The signing secret is never included.","parameters":[{"schema":{"type":"integer","minimum":1,"maximum":100,"default":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string"},"required":false,"name":"before","in":"query"},{"schema":{"type":"string","enum":["active","paused","error"]},"required":false,"name":"status","in":"query"},{"schema":{"type":"string"},"required":false,"name":"event","in":"query"},{"schema":{"type":"string"},"required":false,"name":"server_id","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"triggers":{"type":"array","items":{"$ref":"#/components/schemas/Trigger"}},"next_before":{"type":["string","null"]}},"required":["triggers","next_before"]}}}},"400":{"description":"Bad Request — the body or query did not match the schema.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body","invalid_query"]},"issues":{"type":"array","items":{},"description":"Zod issues, when the route reports them"}},"required":["error"]}}}}}}},"/v1/triggers":{"post":{"deprecated":true,"summary":"Subscribe a webhook to an event","externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979 — canonical path and alias window"},"description":"DEPRECATED alias of `POST /v1/webhook-endpoints` (ent#979), kept for two releases. Same handler, same required scope; switch the path and nothing else changes.\n\nThe response is the ONLY time the signing secret is returned in plaintext. `webhook_url` is refused when it names a loopback, private or metadata host; DNS is re-checked at dispatch, so passing here is not a promise it will resolve publicly later.\n\n`event` is validated against a regex, NOT against a list of events the platform emits. A name that matches the shape but that nothing publishes is accepted with 201 and then never fires, with no signal to the subscriber.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":128},"event":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[a-z0-9_]+(\\.[a-z0-9_]+)*$"},"server_id":{"type":"string","minLength":1},"webhook_url":{"type":"string","maxLength":2048,"format":"uri"}},"required":["name","event","webhook_url"]}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCreated"}}}},"400":{"description":"Bad Request — the body or query did not match the schema.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body","invalid_query"]},"issues":{"type":"array","items":{},"description":"Zod issues, when the route reports them"}},"required":["error"]}}}}}},"get":{"deprecated":true,"summary":"List triggers","externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979 — canonical path and alias window"},"description":"DEPRECATED alias of `GET /v1/webhook-endpoints` (ent#979), kept for two releases. Same handler, same required scope; switch the path and nothing else changes.\n\nSame cursor shape as /v1/sessions. The signing secret is never included.","parameters":[{"schema":{"type":"integer","minimum":1,"maximum":100,"default":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string"},"required":false,"name":"before","in":"query"},{"schema":{"type":"string","enum":["active","paused","error"]},"required":false,"name":"status","in":"query"},{"schema":{"type":"string"},"required":false,"name":"event","in":"query"},{"schema":{"type":"string"},"required":false,"name":"server_id","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"triggers":{"type":"array","items":{"$ref":"#/components/schemas/Trigger"}},"next_before":{"type":["string","null"]}},"required":["triggers","next_before"]}}}},"400":{"description":"Bad Request — the body or query did not match the schema.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body","invalid_query"]},"issues":{"type":"array","items":{},"description":"Zod issues, when the route reports them"}},"required":["error"]}}}}}}},"/v1/whoami":{"get":{"summary":"What this credential is","description":"The cheapest way to prove a key works and to see which org, project and environment it resolves to. `key.scopes` is what the key holds, which is not the same as what each route requires.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Whoami"}}}}}}},"/v1/policies":{"get":{"description":"List policy rules for the authenticated org, sorted by order ASC.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Policy"}}}}}}},"post":{"description":"Create a new policy rule.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PolicyCreate"}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Policy"}}}}}}},"/v1/policies/{id}":{"get":{"summary":"Read one policy rule","description":"The same row the list returns, by id. 404 when the id belongs to another org: the org predicate excludes it from the query, so the handler cannot tell 'other tenant' from 'absent' — and must not, because telling them apart leaks existence.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Policy"}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}},"patch":{"description":"Update a policy rule. Cross-org ids return 404. Invalidates the per-org cache on success.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PolicyUpdate"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Policy"}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}},"delete":{"description":"Delete a policy rule. Cross-org ids return 404. Invalidates the per-org cache on success.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"No Content"},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}}},"/v1/policies/reorder":{"post":{"description":"Reorder policy rules. Atomic: if any id does not belong to the org, the entire operation rolls back and returns 404.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PolicyReorder"}}}},"responses":{"204":{"description":"No Content"},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}}},"/v1/policy-evaluations":{"get":{"description":"List policy evaluations for the authenticated org, newest first. `limit` is clamped at 100.","parameters":[{"schema":{"type":"integer","minimum":1,"default":50},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PolicyEvaluation"}}}}}}}},"/v1/evaluations":{"get":{"deprecated":true,"description":"Deprecated alias of `GET /v1/policy-evaluations` (ent#979), kept for two releases. Same handler, same required scope; switch the path and nothing else changes.","parameters":[{"schema":{"type":"integer","minimum":1,"default":50},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PolicyEvaluation"}}}}}}}},"/v1/consumers":{"post":{"summary":"Create the titular, without a document","description":"Creates the holder of the money as UNVERIFIED. There is deliberately no field for a document: the document→consumer link is born exactly once, already verified, from an approved account application. A body carrying `document` is REFUSED with 400 rather than accepted with the field ignored — being told is the point, because a caller who believed the document was stored would carry that belief into every later call.\n\nThe id is server-minted (`cons_` + nanoid). The free caller-supplied string is the shape this resource replaces.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"display_name":{"type":"string","minLength":1,"maxLength":200},"metadata":{"type":"object","additionalProperties":{}}},"additionalProperties":false}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"display_name":{"type":["string","null"]},"metadata":{"type":"object","additionalProperties":{}},"status":{"type":"string","enum":["unverified","verified"]},"document":{"type":["string","null"]},"document_verified_at":{"type":["string","null"]},"document_source":{"type":["string","null"]},"person_type":{"type":["string","null"]},"tax_type":{"type":["string","null"]},"created_at":{"type":"string"}},"required":["id","display_name","metadata","status","document","document_verified_at","document_source","person_type","tax_type","created_at"]}}}},"400":{"description":"Bad Request — the body or query did not match the schema.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body","invalid_query"]},"issues":{"type":"array","items":{},"description":"Zod issues, when the route reports them"}},"required":["error"]}}}}}},"get":{"summary":"List titulars","description":"`verified=true|false` filters on the stored status, which is a GENERATED column derived from whether a document is linked — so the filter cannot disagree with the row it returns.","parameters":[{"schema":{"type":"string","enum":["true","false"]},"required":false,"name":"verified","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"consumers":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"display_name":{"type":["string","null"]},"metadata":{"type":"object","additionalProperties":{}},"status":{"type":"string","enum":["unverified","verified"]},"document":{"type":["string","null"]},"document_verified_at":{"type":["string","null"]},"document_source":{"type":["string","null"]},"person_type":{"type":["string","null"]},"tax_type":{"type":["string","null"]},"created_at":{"type":"string"}},"required":["id","display_name","metadata","status","document","document_verified_at","document_source","person_type","tax_type","created_at"]}}},"required":["consumers"]}}}},"400":{"description":"Bad Request — the body or query did not match the schema.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body","invalid_query"]},"issues":{"type":"array","items":{},"description":"Zod issues, when the route reports them"}},"required":["error"]}}}}}}},"/v1/consumers/{id}":{"get":{"summary":"Read one titular, with the ids of what hangs off it","description":"The hub: the row plus `wallet_id`, `funding_source_ids`, `mandate_ids` and `dda_subscription_documents`, so a caller navigates from the holder instead of filtering every sub-resource collection.\n\n`document` is masked here and everywhere else on this surface: last four characters, the rest replaced. Pix keys have no section because they are not a table — a registered key is cached on its funding source, so it is reached through `funding_source_ids`.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"display_name":{"type":["string","null"]},"metadata":{"type":"object","additionalProperties":{}},"status":{"type":"string","enum":["unverified","verified"]},"document":{"type":["string","null"]},"document_verified_at":{"type":["string","null"]},"document_source":{"type":["string","null"]},"person_type":{"type":["string","null"]},"tax_type":{"type":["string","null"]},"created_at":{"type":"string"}},"required":["id","display_name","metadata","status","document","document_verified_at","document_source","person_type","tax_type","created_at"]}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}},"patch":{"summary":"Update the mutable fields of a titular","description":"`display_name`, `metadata` and `tax_type`. `document`, `person_type`, the id and the org are immutable and are refused — by this schema and, independently, by a database trigger, so reaching past one still meets the other.\n\nA `tax_type` transition is recorded with its previous value, its new value and the key that asked, in the same transaction as the update. A patch that leaves `tax_type` alone, or resends its current value, records nothing.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"display_name":{"type":["string","null"],"minLength":1,"maxLength":200},"metadata":{"type":"object","additionalProperties":{}},"tax_type":{"type":"string","enum":["PJ","MEI","ME","PF"]}},"additionalProperties":false}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"display_name":{"type":["string","null"]},"metadata":{"type":"object","additionalProperties":{}},"status":{"type":"string","enum":["unverified","verified"]},"document":{"type":["string","null"]},"document_verified_at":{"type":["string","null"]},"document_source":{"type":["string","null"]},"person_type":{"type":["string","null"]},"tax_type":{"type":["string","null"]},"created_at":{"type":"string"}},"required":["id","display_name","metadata","status","document","document_verified_at","document_source","person_type","tax_type","created_at"]}}}},"400":{"description":"Bad Request — the body or query did not match the schema.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body","invalid_query"]},"issues":{"type":"array","items":{},"description":"Zod issues, when the route reports them"}},"required":["error"]}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}}},"/v1/wallets":{"post":{"description":"Create a per-agent wallet. Seeds a zero-balance row in the requested currency.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WalletCreate"}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Wallet"}}}}}},"get":{"description":"List wallets in the caller's project. Optional filters: status, agent_id.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"wallets":{"type":"array","items":{"$ref":"#/components/schemas/Wallet"}}},"required":["wallets"]}}}}}}},"/v1/wallets/{id}":{"get":{"description":"Fetch a wallet plus its per-currency balances. Cross-tenant ids return 404.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Wallet"}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}}},"/v1/wallets/{id}/ledger":{"post":{"description":"Post a ledger entry. Admin role. Idempotent on (wallet_id, attempt_id, kind) and (wallet_id, kind, external_ref). Returns 200 with the prior row on retry, 201 on fresh insert.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WalletLedgerPost"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WalletLedgerEntry"}}}},"201":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WalletLedgerEntry"}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}},"get":{"description":"Paginated ledger view, newest first. Cursor via `before_id`. Optional `kind` filter.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"entries":{"type":"array","items":{"$ref":"#/components/schemas/WalletLedgerEntry"}},"next_before":{"type":["string","null"]}},"required":["entries","next_before"]}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}}},"/v1/wallets/{id}/funding-sources":{"post":{"description":"Bind a connected_accounts row as a funding rail for this wallet. Admin role. The funding bridge converts the connection's webhook events into kind=fund ledger entries.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WalletFundingSourceBind"}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WalletFundingSource"}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}},"get":{"description":"List funding-source bindings for this wallet.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"funding_sources":{"type":"array","items":{"$ref":"#/components/schemas/WalletFundingSource"}}},"required":["funding_sources"]}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}}},"/v1/wallets/{id}/funding-sources/{connection_id}/{currency}":{"delete":{"description":"Unbind a funding source. Admin role.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"connection_id","in":"path"},{"schema":{"type":"string","enum":["BRL","USD","MXN","COP","ARS","USDC","BRLA"]},"required":true,"name":"currency","in":"path"}],"responses":{"204":{"description":"No Content"},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}}},"/v1/wallets/{id}/execute":{"post":{"description":"Drive the F2.M4 gateway lifecycle: policy → mandate → wallet hold → route → execute → wallet settle → audit. Admin role. HTTP status mirrors the GatewayPaymentResult.status (200 completed, 402 requires-approval, 403 denied, 422 failed).","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WalletExecute"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GatewayPaymentResult"}}}},"402":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GatewayPaymentResult"}}}},"403":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GatewayPaymentResult"}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}},"422":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GatewayPaymentResult"}}}}}}},"/v1/wallets/{id}/recon-anomalies":{"get":{"description":"List reconciliation anomalies the engine has flagged. Default status filter is `open`.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"anomalies":{"type":"array","items":{"$ref":"#/components/schemas/WalletReconAnomaly"}}},"required":["anomalies"]}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}}},"/v1/wallets/{id}/recon-anomalies/{aid}":{"post":{"description":"Operator marks an open anomaly as `resolved` or `dismissed`. Admin role. Idempotent on the partial unique covering open rows.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"aid","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WalletAnomalyResolve"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WalletReconAnomaly"}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}}},"/v1/agents/{did}/revoke":{"post":{"summary":"Revoke an agent identity","description":"Take authority away from an agent identity: sets its status to `revoked` and cascades to every one of its non-revoked signing keys, in one transaction.\n\n`revoked` is not `retired`. Retired is a graceful exit; revoked is authority withdrawn, possibly adversarial. Both are reachable states and this verb writes the second one.\n\nWHAT IT STOPS: the agent can no longer be paid as an `agent:<id>` payee, and can no longer mint a new KYA-bearing mandate.\n\nWHAT IT DOES NOT STOP: spending under mandates already signed. Those were signed by the CONSUMER, and a tenant's administration verb does not revoke a third party's authorization. The consequence is that revoking does not by itself halt money already authorized — see ent#1067. If that is what you need, revoke the mandates.\n\nA `{did}` belonging to another org returns 404, not 403: 403 would confirm that the identity exists, which is what a caller sweeping DIDs is asking.","parameters":[{"schema":{"type":"string"},"required":true,"name":"did","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"did":{"type":"string"},"status":{"type":"string","enum":["revoked"]},"revoked_at":{"type":"string"},"revoked_kids":{"type":"array","items":{"type":"string"},"description":"The signing keys the cascade revoked, in creation order."},"limits":{"type":"object","properties":{"stops":{"type":"array","items":{"type":"string"}},"does_not_stop":{"type":"string"},"issue":{"type":"string"}},"required":["stops","does_not_stop","issue"],"description":"What this verb reaches and what it does not. Served with every 200 so the limit travels with the act."}},"required":["did","status","revoked_at","revoked_kids","limits"]}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}},"409":{"description":"Conflict — the agent is already revoked.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["agent_already_revoked"]}},"required":["error"]}}}}}}},"/v1/approvals/health":{"get":{"summary":"Approval queue health","description":"How deep this organization's approval queue is, how long the oldest hold has been waiting, and whether the sweeps are running.\n\n`pending_count` and `oldest_pending_age_seconds` are computed over the caller's own organization, and the second is null exactly when the first is 0: both come from the same `status = 'pending'` filter over the same rows.\n\n`last_sweep_at` IS NOT SCOPED TO THE CALLER, and cannot be. It is the newest run of the expiry and orphan sweeps for the whole deployment: the table it reads holds one row per sweep type and carries no organization column, so the value moves for reasons that have nothing to do with this organization. Read it as 'the sweeps are alive here', never as 'my queue was swept'. Null means neither sweep has ever recorded a run.\n\nThe age is in whole seconds, rounded.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"pending_count":{"type":"integer"},"oldest_pending_age_seconds":{"type":["integer","null"],"description":"Age of the oldest pending hold, in seconds. Null when none is pending."},"last_sweep_at":{"type":["string","null"],"format":"date-time","description":"Deployment-wide, not per-organization. See the description."}},"required":["status","pending_count","oldest_pending_age_seconds","last_sweep_at"]}}}}}}},"/v1/approvals/{id}":{"get":{"summary":"Read one approval","description":"One held call, with the arguments recorded when it was held.\n\nSCOPE. The lookup matches the id AND the caller's organization, so an id from another organization answers 404 rather than 403: a 403 would confirm the id exists, which is what a caller sweeping ids is asking. Inside the organization this read is NOT project-scoped, and the agent-facing poll at `GET /v1/approvals/{id}/status` is: a key bound to one project reads holds raised under a sibling project here, and does not there.\n\n`tool_input` IS NOT THE CALL. It is what the lane that raised the hold recorded, and two things happen to it on the way in. A hold raised on the session execute path carries `{}`, because that path hands the policy engine no tool input at all and the engine stores an empty object when it gets none. A hold raised on the proxy path carries `{ server, method, endpoint, body, params, headers }`, with the `authorization` and `cookie` request headers removed before the row is written. So an empty object here means 'this lane records nothing', not 'the agent sent nothing'.\n\n`tool_name` HAS NO SINGLE FORMAT, because four lanes write it. The proxy path writes `<server>:<method>:<endpoint>` with the leading slash dropped and the remaining slashes turned into colons (`asaas`, `POST`, `/payments/create` becomes `asaas:post:payments:create`). The session execute path writes the caller's own `tool` string verbatim, which is any non-empty string that caller chose. The over-cap settlement lane writes `codespar_pay`. The operator account-status lane writes `admin:account_status_set`. Match on it only knowing which lane you are reading.\n\n`expires_at` is the end of the hold, not a display hint: once it passes, the row stops being decidable and the expiry sweep moves it to `expired`.\n\n`execution_result` stays null until an approved call has actually been replayed, which is the state of every `pending` row and of every `denied` one. The row's `decision_history` and `metadata` columns are deliberately not in this response.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`apv_` + nanoid(16)."},"org_id":{"type":"string"},"project_id":{"type":["string","null"],"description":"Null for a hold raised outside any project."},"session_id":{"type":["string","null"]},"agent_id":{"type":"string"},"matched_rule_id":{"type":"string","description":"The policy rule that held the call."},"matched_rule_name":{"type":"string"},"tool_name":{"type":"string","description":"The held call, in whatever form the lane that raised the hold writes. There is no single format: see the operation description before matching on it."},"tool_input":{"type":"object","additionalProperties":{},"description":"The arguments as RECORDED, which is not always the arguments as sent. Empty for a hold raised on the session execute path, and header-stripped on the proxy path."},"status":{"type":"string","enum":["pending","approved","denied","expired","execution_failed"]},"created_at":{"type":"string","format":"date-time"},"expires_at":{"type":"string","format":"date-time","description":"When the hold stops being decidable."},"decided_at":{"type":["string","null"],"format":"date-time"},"decided_by":{"type":["string","null"],"description":"The approver, null while pending."},"decision_reason":{"type":["string","null"]},"executed_at":{"type":["string","null"],"format":"date-time"},"execution_result":{"type":["object","null"],"additionalProperties":{},"description":"Null until an approved call has been replayed. Open shape, passed through verbatim."}},"required":["id","org_id","project_id","session_id","agent_id","matched_rule_id","matched_rule_name","tool_name","tool_input","status","created_at","expires_at","decided_at","decided_by","decision_reason","executed_at","execution_result"]}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}}},"/v1/approvals/{id}/status":{"get":{"summary":"Poll a held call for its outcome","description":"The caller's half of the approval loop. When a policy rule holds a tool call, the execute path answers 403 with `approval_id` and `expires_at` in the body; this turns that id into an outcome.\n\nSCOPE. Organization AND project, with a project-less credential matching a project-less row. Anything else is `approval_not_found`, which is also the answer for an id that never existed. Note the literal: this route does not answer the `not_found` the operator read answers.\n\n`approval_status` is `pending`, meaning poll again, or one of four terminal values: `approved` (decided and replayed, with the outcome in `execution_result`), `denied` (no execution happened), `expired` (the hold ran out before anyone decided), and `execution_failed` (approved, but the replay errored and `execution_result.error` says how).\n\nA stored status this build does not recognize is reported as `pending` rather than as a terminal value. That is deliberate: a client polling a row written by a newer schema keeps polling instead of concluding something false about it.\n\n`execution_result` is null until the replay writes one. What it writes today is `{ ok, upstream_status, data, error, duration_ms }`, plus `mocked: true` when a mock executor produced the outcome. It is handed through verbatim, so read it as an open object rather than as a closed shape.\n\n`tool_name` here is the same string the operator read returns, with the same caveat about its format. There is no streaming variant of this endpoint.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"approval_id":{"type":"string"},"approval_status":{"type":"string","enum":["pending","approved","denied","expired","execution_failed"]},"tool_name":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"expires_at":{"type":"string","format":"date-time"},"decided_at":{"type":["string","null"],"format":"date-time"},"decided_by":{"type":["string","null"]},"decision_reason":{"type":["string","null"]},"executed_at":{"type":["string","null"],"format":"date-time"},"execution_result":{"type":["object","null"],"additionalProperties":{}}},"required":["approval_id","approval_status","tool_name","created_at","expires_at","decided_at","decided_by","decision_reason","executed_at","execution_result"]}}}},"404":{"description":"Not Found: no approval with that id in the caller's org and project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["approval_not_found"]}},"required":["error"]}}}}}}},"/v1/generate/servers":{"get":{"summary":"List the project's generated servers","description":"The MCP servers the Generate flow has persisted under the active project, newest first.\n\nThis is a summary read: the stored tool definitions, the source endpoints they came from and the generated server code are NOT in the response. `tool_count` is the length of the stored tool array, computed in the query.\n\n`status` is `draft` for a server that has been onboarded but not wired into the router, `routable` once it has been activated, `disabled` when it has been soft-retired.\n\nThe write side refuses a NEW name once the project already holds 25 servers, and re-onboarding an existing name replaces it in place, so this read's 100-row cap is not the limit you will meet. Every generated server is project-scoped: a credential that resolves to no project gets 400 rather than an empty list, because an empty list would read as 'this project has none'.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"servers":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"`gen_` + nanoid(16)."},"name":{"type":"string","description":"Developer-chosen slug, unique per project."},"version":{"type":"string"},"description":{"type":"string"},"status":{"type":"string","enum":["draft","routable","disabled"]},"tool_count":{"type":"integer"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"required":["id","name","version","description","status","tool_count","created_at","updated_at"]}}},"required":["servers"]}}}},"400":{"description":"Bad Request: the credential resolves to no project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["project_required"]}},"required":["error"]}}}}}}},"/v1/audit-events":{"get":{"summary":"Browse the audit chain","description":"A read-only page of the caller's own chain, newest first. This is the browse endpoint, not the export one: it emits no manifest, no signature and no `audit_export` chain entry, so paging through it leaves no trace and proves nothing offline.\n\nScoped to the credential's org with no way to widen it. `event_type` is an exact match unless it ends with a dot, in which case it is a prefix match, so `tool_call.` returns the succeeded and the failed variants in one pass. The default window is the last seven days.\n\nPagination walks backwards: pass the previous response's `next_before_sequence` as `before_sequence`. It is null on the last page, and a full page whose `sequence_number` values happen to end at the chain floor still returns a non-null cursor, so the last call is the one that returns fewer rows than `limit`.","parameters":[{"schema":{"type":"string","description":"Exact match, or a prefix match when it ends with a dot."},"required":false,"description":"Exact match, or a prefix match when it ends with a dot.","name":"event_type","in":"query"},{"schema":{"type":"string","format":"date-time","description":"ISO 8601. Defaults to seven days ago."},"required":false,"description":"ISO 8601. Defaults to seven days ago.","name":"from","in":"query"},{"schema":{"type":"string","format":"date-time","description":"ISO 8601. Defaults to now."},"required":false,"description":"ISO 8601. Defaults to now.","name":"to","in":"query"},{"schema":{"type":"integer","description":"Clamped to 1..200. Defaults to 50, and falls back to 50 when the value is 0 or is not a number; a negative value becomes 1."},"required":false,"description":"Clamped to 1..200. Defaults to 50, and falls back to 50 when the value is 0 or is not a number; a negative value becomes 1.","name":"limit","in":"query"},{"schema":{"type":"integer","description":"Returns entries strictly below this sequence. Use the previous page's `next_before_sequence`."},"required":false,"description":"Returns entries strictly below this sequence. Use the previous page's `next_before_sequence`.","name":"before_sequence","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"events":{"type":"array","items":{"type":"object","properties":{"sequence_number":{"type":"integer","description":"Per-org, gap-free and never reused. The chain's ordering."},"event_type":{"type":"string"},"happened_at":{"type":"string","format":"date-time"},"payload":{"type":"object","additionalProperties":{}},"prev_hash":{"type":"string","description":"The previous entry's `entry_hash`, which is what binds one row to the row before it. The first entry of a chain carries the genesis value instead."},"entry_hash":{"type":"string"}},"required":["sequence_number","event_type","happened_at","payload","prev_hash","entry_hash"]}},"next_before_sequence":{"type":["integer","null"],"description":"The lowest sequence on this page when it was full; null when it was not."}},"required":["events","next_before_sequence"]}}}},"400":{"description":"Bad Request. `invalid_before_sequence` when the cursor is not a number, `invalid_iso_8601` when `from` or `to` is not a date this server can parse, `from_after_to` when the window is inverted. An out-of-range `limit` is NOT an error here: it is clamped.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_before_sequence","invalid_iso_8601","from_after_to"]}},"required":["error"]}}}}}}},"/v1/audit-events/health":{"get":{"summary":"What the verifier says about this chain","description":"The chain's integrity report, and the endpoint to poll if you monitor one thing on this resource. It combines the background verifier's persisted watermark with a live re-check of the five newest rows performed during this request, so it keeps answering truthfully while the verifier worker is down.\n\nRead `actionable_status`, not `status`. `status` is the older two-valued field kept for compatibility, and it folds FOUR distinct situations into `degraded` — a detected break, an open incident, an uncertified stretch of chain, and a chain whose verification has not reached its tip while rows sit above the watermark. It also reads `healthy` over `catching_up`, which is a real state with a real backlog.\n\nThis read has side effects, on purpose. A writer-tip failure opens an incident and an unverifiable segment is recorded, both keyed so that repeat polling refreshes one row rather than accumulating: evidence found while answering an HTTP request has to outlive the response, which the response alone does not.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["healthy","degraded"],"description":"The two-valued summary, kept for compatibility. `degraded` covers exactly four situations: a detected break (`actionable_status` `broken`), an open incident (`degraded`), an uncertified stretch of chain (`link_unverifiable`), and a chain still under `verifying` whose `last_sequence_number` is above `verification.watermark_sequence`. Read `actionable_status` for which one.\n\nNote what it does NOT cover: `catching_up` reads `healthy` here, on purpose. That state means the first walk DID complete and the verifier is merely behind on newer events, which is transient and self-healing. So `healthy` in this field is not the same claim as `actionable_status: \"healthy\"`."},"last_sequence_number":{"type":"integer","description":"Chain tip. 0 for an empty chain."},"last_checked_at":{"type":"string","format":"date-time"},"detail":{"type":"string","description":"One human sentence explaining the status. Under `verifying` it is also the only field that separates the two ways to get there: it reads `no verification run has ever completed` when no watermark row exists, and `initial verification walk in progress` when one does."},"actionable_status":{"type":"string","enum":["verifying","healthy","catching_up","link_unverifiable","degraded","broken"],"description":"`broken` means a row is not what the chain says it is. `degraded` means an incident is open. `link_unverifiable` is the absence of proof rather than evidence of tampering: a stretch of chain has no verified link, either because the rows that would close it are gone or because a known break pins the verifier below it. `catching_up` is transient and self-healing.\n\n`verifying` means no walk has reached chain tip yet — which is not the same as no walk having finished. It is returned when there is no watermark row at all, and equally when `initial_walk_completed_at` is unset on the org's config row, which covers a walk that ran to completion without reaching the tip AND an org that has no config row at all. A chain can therefore sit at `verifying` indefinitely; use `detail` and `verification.watermark_sequence` to tell which case you are in."},"verification":{"type":"object","properties":{"window_days":{"type":"integer"},"watermark_sequence":{"type":"integer","description":"Everything at or below this has been walked and verified. 0 when nothing has."},"watermark_at":{"type":["string","null"],"format":"date-time"},"watermark_entry_hash":{"type":["string","null"]},"coverage_ratio":{"type":"number","description":"`watermark_sequence` over `last_sequence_number`, CLAMPED to 1.0. 1.0 for an empty chain. The clamp costs information: a chain truncated below the watermark has a raw quotient above 1.0 and reads a perfectly ordinary 1.0 here, so do not detect truncation from this number — `writer_tip_check_passed` and `actionable_status` carry it."},"hot_bucket_oldest_verified_at":{"type":["string","null"],"format":"date-time"},"hot_bucket_max_staleness_seconds":{"type":"integer"},"writer_tip_check_passed":{"type":["boolean","null"],"description":"The live re-check performed while answering this request. It walks the five newest rows, recomputing each entry hash and each link to the row below it, and it ALSO carries the tail-truncation verdict, which is not a check on those five rows at all.\n\n`false` therefore has two causes: one of the five rows failed its hash or link, or `verification.watermark_sequence` is above `last_sequence_number`, meaning rows were removed from the tail after they had been certified. `actionable_status` is `broken` either way.\n\n`null` is narrower than 'the chain has no rows': it means the chain has no rows AND nothing above sequence 0 was ever certified. A chain whose rows were deleted after certification also has no rows to check, and reads `false` here, not `null` — that is the case this detector exists for, and it is the one where reading `null` would hide it."},"writer_tip_check_at":{"type":"string","format":"date-time"},"chain_link_check":{"type":"object","properties":{"unverifiable_segments":{"type":"integer"},"oldest_unverifiable_segment":{"type":["object","null"],"properties":{"from_sequence":{"type":"integer"},"to_sequence":{"type":"integer"},"reason":{"type":"string","enum":["sequence_gap","no_anchor"]},"first_detected_at":{"type":"string","format":"date-time"}},"required":["from_sequence","to_sequence","reason","first_detected_at"]},"writer_tip_link_unverifiable":{"type":"boolean"},"watermark_pinned_by_break_at":{"type":["integer","null"],"description":"Sequence of the oldest known break above the watermark. While this is set, nothing above the watermark is verified, acknowledged incident or not."}},"required":["unverifiable_segments","oldest_unverifiable_segment","writer_tip_link_unverifiable","watermark_pinned_by_break_at"]}},"required":["window_days","watermark_sequence","watermark_at","watermark_entry_hash","coverage_ratio","hot_bucket_oldest_verified_at","hot_bucket_max_staleness_seconds","writer_tip_check_passed","writer_tip_check_at","chain_link_check"]},"incidents":{"type":"object","properties":{"open_count":{"type":"integer"},"acknowledged_count_30d":{"type":"integer"},"open_truncated":{"type":"boolean","description":"True when `open` holds fewer than `open_count`."},"open":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"detected_at":{"type":"string","format":"date-time"},"detected_at_sequence":{"type":"integer"}},"required":["id","detected_at","detected_at_sequence"]},"maxItems":5,"description":"At most five, newest first."}},"required":["open_count","acknowledged_count_30d","open_truncated","open"]},"initial_walk":{"type":"object","properties":{"in_progress":{"type":"boolean","description":"True while no forward walk has reached chain tip. This is the same condition that puts `actionable_status` at `verifying`, so it is also true for an org with no config row."},"started_at":{"type":["string","null"],"format":"date-time"},"events_processed":{"type":"integer"}},"required":["in_progress","started_at","events_processed"]}},"required":["status","last_sequence_number","last_checked_at","detail","actionable_status","verification","incidents","initial_walk"]}}}}}}},"/v1/audit-events/incidents":{"get":{"summary":"List chain-integrity incidents","description":"Every incident is a detected break in the org's own chain, opened by the verifier or by a health read. Newest first, capped at 100 rows, and NOT paginated: an org with more than 100 incidents cannot reach the older ones from here.\n\n`status` defaults to `open`. It is a comma-separated list; `open`, `acknowledged` and `all` are recognized and any other entry is silently dropped, so `open,bogus` behaves as `open`. `all` anywhere in the list returns every incident. Only a list where nothing is recognized is refused — including an empty `status=`, which is a present-but-unrecognized value rather than an absent one and so does not fall back to the default.","parameters":[{"schema":{"type":"string","description":"Comma-separated: `open`, `acknowledged`, `all`. Defaults to `open`."},"required":false,"description":"Comma-separated: `open`, `acknowledged`, `all`. Defaults to `open`.","name":"status","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"incidents":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"org_id":{"type":"string"},"detected_at":{"type":"string","format":"date-time"},"detected_at_sequence":{"type":"integer","description":"The sequence the break was found at. Unique per org, so one break yields one incident however often it is re-observed."},"discovered_by_run_id":{"type":["string","null"],"format":"uuid"},"status":{"type":"string","enum":["open","acknowledged"]},"acknowledged_at":{"type":["string","null"],"format":"date-time"},"acknowledged_by":{"type":["string","null"],"description":"The `x-codespar-user` value of the person who acknowledged it."},"incident_reference":{"type":["string","null"]},"resolution_summary":{"type":["string","null"]},"anpd_notification_status":{"type":["string","null"],"enum":["not_required","notified"]},"personal_data_affected":{"type":["boolean","null"]},"affected_from_sequence":{"type":["integer","null"]},"affected_to_sequence":{"type":["integer","null"]},"ack_event_entry_hash":{"type":["string","null"],"description":"Hash of the chain entry the acknowledgement itself wrote, so the ack is auditable by the same walk as everything else."},"created_at":{"type":"string","format":"date-time"}},"required":["id","org_id","detected_at","detected_at_sequence","discovered_by_run_id","status","acknowledged_at","acknowledged_by","incident_reference","resolution_summary","anpd_notification_status","personal_data_affected","affected_from_sequence","affected_to_sequence","ack_event_entry_hash","created_at"]}}},"required":["incidents"]}}}},"400":{"description":"Bad Request. `invalid_status` when none of the comma-separated values is one of `open`, `acknowledged` or `all`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_status"]}},"required":["error"]}}}}}}},"/v1/audit-events/incidents/{id}/acknowledge":{"post":{"summary":"Acknowledge a chain-integrity incident","description":"Closes an open incident with a written resolution, and appends that statement to the audit chain as an entry of its own, so the acknowledgement is auditable by the same walk as the break it answers. The new entry's hash comes back on the incident as `ack_event_entry_hash`, and its sequence as `chain_sequence_number`.\n\nA chain break has no chain-level repair. Acknowledging is a statement about your process, not a fix: the verifier stays pinned below the break and `GET /v1/audit-events/health` keeps reporting the stretch above it as uncertified afterwards.\n\nThis is a person's act, not a key's: an API key is refused with 403 `human_session_required`, and the `x-codespar-user` header must name an admin or owner. That value is stored as the incident's `acknowledged_by` and carried into the chain entry, which is what makes the acknowledgement attributable. `id` is the incident UUID the list returns; an incident belonging to another org is 404, the same body as one that does not exist.\n\nNot idempotent, and does not pretend to be. A second acknowledgement of the same incident is 409 with the incident as it stands, which is also what a concurrent acknowledgement that lost the race receives: the update only moves `open` to `acknowledged`, and the losing transaction rolls its chain entry back rather than writing a second one.","parameters":[{"schema":{"type":"string","format":"uuid","description":"The incident UUID."},"required":true,"description":"The incident UUID.","name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"The acting member's user id. Required, and it must be an admin or owner of the org: it is who the operation is authorized as."},"required":true,"description":"The acting member's user id. Required, and it must be an admin or owner of the org: it is who the operation is authorized as.","name":"x-codespar-user","in":"header"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"resolution_summary":{"type":"string","minLength":1,"maxLength":8000,"description":"Required. C0 control characters and DEL are stripped before both the emptiness check and the length check, so the 8000 limit is measured on the stripped text and a summary that is only whitespace counts as empty."},"anpd_notification_status":{"type":"string","enum":["not_required","notified"],"description":"Terminal only. `pending_assessment` is refused with its own code: an incident cannot be closed while the notification question is still open."},"personal_data_affected":{"type":"boolean","description":"Required. There is no default."},"incident_reference":{"type":["string","null"],"maxLength":256,"description":"Your ticket or case number. Control characters are stripped; null is the same as omitting it."},"affected_from_sequence":{"type":["number","null"],"description":"Send with `affected_to_sequence` or with neither. One without the other is refused, and `from` above `to` is refused."},"affected_to_sequence":{"type":["number","null"]}},"required":["resolution_summary","anpd_notification_status","personal_data_affected"]}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"incident":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"org_id":{"type":"string"},"detected_at":{"type":"string","format":"date-time"},"detected_at_sequence":{"type":"integer","description":"The sequence the break was found at. Unique per org, so one break yields one incident however often it is re-observed."},"discovered_by_run_id":{"type":["string","null"],"format":"uuid"},"status":{"type":"string","enum":["open","acknowledged"]},"acknowledged_at":{"type":["string","null"],"format":"date-time"},"acknowledged_by":{"type":["string","null"],"description":"The `x-codespar-user` value of the person who acknowledged it."},"incident_reference":{"type":["string","null"]},"resolution_summary":{"type":["string","null"]},"anpd_notification_status":{"type":["string","null"],"enum":["not_required","notified"]},"personal_data_affected":{"type":["boolean","null"]},"affected_from_sequence":{"type":["integer","null"]},"affected_to_sequence":{"type":["integer","null"]},"ack_event_entry_hash":{"type":["string","null"],"description":"Hash of the chain entry the acknowledgement itself wrote, so the ack is auditable by the same walk as everything else."},"created_at":{"type":"string","format":"date-time"}},"required":["id","org_id","detected_at","detected_at_sequence","discovered_by_run_id","status","acknowledged_at","acknowledged_by","incident_reference","resolution_summary","anpd_notification_status","personal_data_affected","affected_from_sequence","affected_to_sequence","ack_event_entry_hash","created_at"]},"chain_sequence_number":{"type":"integer","description":"Sequence of the chain entry this acknowledgement wrote."}},"required":["incident","chain_sequence_number"]}}}},"400":{"description":"Bad Request. `pending_assessment_not_terminal` when `anpd_notification_status` is `pending_assessment`, which is a working state and never a closing one; it is checked before everything else. Otherwise `field_invalid`, naming the field and why: `empty` and `too_long` for `resolution_summary` (with `limit: 8000`), `invalid_type` and `too_long` for `incident_reference` (with `limit: 256`), `invalid_enum` for `anpd_notification_status`, `invalid_type` for `personal_data_affected`, and `paired_required`, `invalid_type` or `range_inconsistent` for the affected-sequence pair. One field is reported per response, the first that fails. Note that a `resolution_summary` that is not a string reports `empty`, not `invalid_type`.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"string","enum":["pending_assessment_not_terminal"]}},"required":["error"]},{"type":"object","properties":{"error":{"type":"string","enum":["field_invalid"]},"field":{"type":"string","enum":["resolution_summary","incident_reference","anpd_notification_status","personal_data_affected","affected_from_sequence","affected_to_sequence"]},"reason":{"type":"string","enum":["empty","too_long","invalid_type","invalid_enum","paired_required","range_inconsistent"]},"limit":{"type":"integer","description":"Present only with `too_long`."}},"required":["error","field","reason"]}]}}}},"401":{"description":"Unauthorized. The `x-codespar-user` header is missing or blank. The credential itself was accepted; what is missing is who is acting.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["unauthenticated"]}},"required":["error"]}}}},"403":{"description":"Forbidden. `human_session_required` when the credential is an API key: this operation is attributed to a person, so a machine key cannot perform it. `insufficient_role` when the `x-codespar-user` member is not an admin or owner of the org. The API-key check runs FIRST, so a key with no `x-codespar-user` header gets this 403 rather than the 401 below.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["human_session_required","insufficient_role"]}},"required":["error"]}}}},"404":{"description":"Not Found. No open or acknowledged incident with that id belongs to the caller's org. An incident that exists under another tenant is indistinguishable from one that does not exist.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["incident_not_found"]}},"required":["error"]}}}},"409":{"description":"Conflict. The incident is already acknowledged, either from an earlier call or from one that won a concurrent race. `incident` carries the current row, and is null only if the row disappeared between losing the race and being re-read.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["already_acknowledged"]},"incident":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"org_id":{"type":"string"},"detected_at":{"type":"string","format":"date-time"},"detected_at_sequence":{"type":"integer","description":"The sequence the break was found at. Unique per org, so one break yields one incident however often it is re-observed."},"discovered_by_run_id":{"type":["string","null"],"format":"uuid"},"status":{"type":"string","enum":["open","acknowledged"]},"acknowledged_at":{"type":["string","null"],"format":"date-time"},"acknowledged_by":{"type":["string","null"],"description":"The `x-codespar-user` value of the person who acknowledged it."},"incident_reference":{"type":["string","null"]},"resolution_summary":{"type":["string","null"]},"anpd_notification_status":{"type":["string","null"],"enum":["not_required","notified"]},"personal_data_affected":{"type":["boolean","null"]},"affected_from_sequence":{"type":["integer","null"]},"affected_to_sequence":{"type":["integer","null"]},"ack_event_entry_hash":{"type":["string","null"],"description":"Hash of the chain entry the acknowledgement itself wrote, so the ack is auditable by the same walk as everything else."},"created_at":{"type":"string","format":"date-time"}},"required":["id","org_id","detected_at","detected_at_sequence","discovered_by_run_id","status","acknowledged_at","acknowledged_by","incident_reference","resolution_summary","anpd_notification_status","personal_data_affected","affected_from_sequence","affected_to_sequence","ack_event_entry_hash","created_at"]}},"required":["error","incident"]}}}},"503":{"description":"Service Unavailable. `lock_timeout`: the per-org chain lock was not granted within the timeout, so nothing was written. Safe to retry.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["lock_timeout"]}},"required":["error"]}}}}}}},"/v1/audit-events/config":{"get":{"summary":"Read the chain verification thresholds","description":"The per-org settings the verifier and the health endpoint read: how far back the chain is walked, and how stale each age bucket is allowed to get before the health report says the verifier is behind.\n\nNever 404. An org with no stored row reads the seeded defaults, with `created_at` and `updated_at` set to the time of the read rather than to a stored timestamp. Those defaults are computed in the handler, not written back, so a repeated read of an org with no row returns a moving `updated_at` — and `GET /v1/audit-events/health` reports that same org as `verifying`, because the absent row is what `initial_walk_completed_at` is read from.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"org_id":{"type":"string"},"verification_window_days":{"type":"integer"},"hot_window_days":{"type":"integer"},"hot_max_staleness_seconds":{"type":"integer"},"warm_window_days":{"type":"integer"},"warm_max_staleness_seconds":{"type":"integer"},"cold_max_staleness_seconds":{"type":"integer"},"initial_walk_completed_at":{"type":["string","null"],"format":"date-time","description":"Null until a forward walk reaches chain tip."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"required":["org_id","verification_window_days","hot_window_days","hot_max_staleness_seconds","warm_window_days","warm_max_staleness_seconds","cold_max_staleness_seconds","initial_walk_completed_at","created_at","updated_at"]}}}}}},"patch":{"summary":"Change the chain verification thresholds","description":"A sparse patch: an omitted field keeps its current value, and the whole merged result is validated, not just the fields you sent. So a patch touching only `hot_window_days` can still be refused for ordering against the `warm` and `verification` windows it did not touch.\n\nA field carrying anything other than a JSON number is IGNORED rather than refused, and the response then shows the unchanged value. A caller who sent `\"3600\"` as a string gets 200 and no change, so compare the response against what you sent.\n\nTwo invariants are enforced. `hot_max_staleness_seconds` may not exceed 259200 seconds, 72 hours, which is a regulatory floor on how long a chain may go unchecked and not a tunable. And the windows must be ordered `hot_window_days` <= `warm_window_days` <= `verification_window_days`. Neither `warm_max_staleness_seconds` nor `cold_max_staleness_seconds` has a ceiling of its own.\n\nAdmin or owner only, and an API key is refused with 403 `human_session_required`: these thresholds decide how loudly a tampered chain reports itself. The `x-codespar-user` header is what the change is authorized against, but unlike an acknowledgement it is not stored on the row and not written to the chain, so the config row does not say who last changed it.","parameters":[{"schema":{"type":"string","minLength":1,"description":"The acting member's user id. Required, and it must be an admin or owner of the org: it is who the operation is authorized as."},"required":true,"description":"The acting member's user id. Required, and it must be an admin or owner of the org: it is who the operation is authorized as.","name":"x-codespar-user","in":"header"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"verification_window_days":{"type":"integer"},"hot_window_days":{"type":"integer"},"hot_max_staleness_seconds":{"type":"integer","description":"Capped at 259200 (72 hours). Above that the whole patch is refused."},"warm_window_days":{"type":"integer"},"warm_max_staleness_seconds":{"type":"integer"},"cold_max_staleness_seconds":{"type":"integer"}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"org_id":{"type":"string"},"verification_window_days":{"type":"integer"},"hot_window_days":{"type":"integer"},"hot_max_staleness_seconds":{"type":"integer"},"warm_window_days":{"type":"integer"},"warm_max_staleness_seconds":{"type":"integer"},"cold_max_staleness_seconds":{"type":"integer"},"initial_walk_completed_at":{"type":["string","null"],"format":"date-time","description":"Null until a forward walk reaches chain tip."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"required":["org_id","verification_window_days","hot_window_days","hot_max_staleness_seconds","warm_window_days","warm_max_staleness_seconds","cold_max_staleness_seconds","initial_walk_completed_at","created_at","updated_at"]}}}},"400":{"description":"Bad Request. `regulatory_minimum_violation` when the merged `hot_max_staleness_seconds` exceeds the ceiling, with `limit` carrying it as a number. `window_ordering_invalid` when the merged windows are out of order, with `limit` carrying the three values as an OBJECT so the caller can see which pair collided — the two bodies give `limit` different types, which is why they are a union here and not one schema. Nothing is written in either case.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"string","enum":["regulatory_minimum_violation"]},"limit":{"type":"integer"}},"required":["error","limit"]},{"type":"object","properties":{"error":{"type":"string","enum":["window_ordering_invalid"]},"limit":{"type":"object","properties":{"hot":{"type":"integer"},"warm":{"type":"integer"},"window":{"type":"integer"}},"required":["hot","warm","window"]}},"required":["error","limit"]}]}}}},"401":{"description":"Unauthorized. The `x-codespar-user` header is missing or blank. The credential itself was accepted; what is missing is who is acting.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["unauthenticated"]}},"required":["error"]}}}},"403":{"description":"Forbidden. `human_session_required` when the credential is an API key: this operation is attributed to a person, so a machine key cannot perform it. `insufficient_role` when the `x-codespar-user` member is not an admin or owner of the org. The API-key check runs FIRST, so a key with no `x-codespar-user` header gets this 403 rather than the 401 below.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["human_session_required","insufficient_role"]}},"required":["error"]}}}}}}},"/v1/bank-consents/{id}":{"get":{"summary":"Read one bank consent","description":"The body is the consent projection: the same shape `POST /v1/bank-consents` returns on create, and the same one the callback and revoke transitions return. The refresh-statement operation in this family is the exception, answering with a run summary rather than a consent.\n\nResolution is scoped to the organization AND the project the credential authenticates for. A consent held by a SIBLING project in the same organization answers 404 here, indistinguishable from one that never existed. That is deliberate rather than incidental: one project's upstream bank grant is never readable from another.\n\nNo token material leaves. The row carries references to the bank's access and refresh tokens; they are dropped before the body is built, and `token_expires_at` is the only thing about the bank token that is returned.\n\nRequires the `consents:read` scope.","parameters":[{"schema":{"type":"string","description":"`ofb_`-prefixed consent id."},"required":true,"description":"`ofb_`-prefixed consent id.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`ofb_`-prefixed. The id every other operation in this family takes."},"bank_id":{"type":"string","description":"Lowercase kebab-case, as supplied when the consent was opened: `itau`, `bradesco`, `bb`, `inter`."},"consumer_id":{"type":"string","description":"The consumer, in the caller's own namespace."},"wallet_id":{"type":["string","null"],"description":"The wallet named on the create request, or null. Written once at creation and never rewritten: neither state transition on this consent touches the column.\n\nIt is a label, not routing. A statement refresh copies this value onto every event it ingests, and nothing downstream reads it: reconciliation resolves a wallet from the funding source behind an event's `connection_id`, and the events this family publishes carry no such key. Binding a consent to a wallet therefore does not, on its own, put the ingested statement into that wallet's reconciliation."},"status":{"type":"string","enum":["pending","authorised","revoked","expired","consumed"],"description":"`authorised` carries the British spelling, which is the column's own and not a typo. `revoked`, `expired` and `consumed` are terminal: no transition leaves them. Read \"is this consent usable now\" from `status` being `authorised`, never from `revoked_at` being null."},"scopes":{"type":"array","items":{"type":"string"},"description":"The OFB scopes the consent was opened for."},"bank_consent_id":{"type":["string","null"],"description":"The bank's own identifier for the consent, as returned when it was opened."},"authorisation_url":{"type":["string","null"],"description":"The URL the consumer opens to authorise the consent at their bank."},"token_expires_at":{"type":["string","null"],"format":"date-time","description":"Expiry of the bank access token. The token references themselves are never returned by this API."},"created_at":{"type":"string","format":"date-time"},"authorized_at":{"type":["string","null"],"format":"date-time","description":"American spelling here, unlike `status: \"authorised\"`; both mirror their own column names. Stamped in the same statement as the transition into `authorised`."},"expires_at":{"type":["string","null"],"format":"date-time","description":"Mirror of the bank-side consent expiry, stamped when the consent is opened."},"revoked_at":{"type":["string","null"],"format":"date-time"},"consumed_at":{"type":["string","null"],"format":"date-time"},"metadata":{"type":"object","additionalProperties":{}}},"required":["id","bank_id","consumer_id","wallet_id","status","scopes","bank_consent_id","authorisation_url","token_expires_at","created_at","authorized_at","expires_at","revoked_at","consumed_at","metadata"]}}}},"404":{"description":"Not Found. No consent with this id in the caller's project. A consent belonging to another project in the same organization answers here too.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/ofb/consents/{id}":{"get":{"deprecated":true,"summary":"Read one bank consent","externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979 — canonical path and alias window"},"description":"DEPRECATED alias of `GET /v1/bank-consents/{id}` (ent#979), kept for two releases. Same handler, same required scope; switch the path and nothing else changes.\n\nThe body is the consent projection: the same shape `POST /v1/bank-consents` returns on create, and the same one the callback and revoke transitions return. The refresh-statement operation in this family is the exception, answering with a run summary rather than a consent.\n\nResolution is scoped to the organization AND the project the credential authenticates for. A consent held by a SIBLING project in the same organization answers 404 here, indistinguishable from one that never existed. That is deliberate rather than incidental: one project's upstream bank grant is never readable from another.\n\nNo token material leaves. The row carries references to the bank's access and refresh tokens; they are dropped before the body is built, and `token_expires_at` is the only thing about the bank token that is returned.\n\nRequires the `consents:read` scope.","parameters":[{"schema":{"type":"string","description":"`ofb_`-prefixed consent id."},"required":true,"description":"`ofb_`-prefixed consent id.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`ofb_`-prefixed. The id every other operation in this family takes."},"bank_id":{"type":"string","description":"Lowercase kebab-case, as supplied when the consent was opened: `itau`, `bradesco`, `bb`, `inter`."},"consumer_id":{"type":"string","description":"The consumer, in the caller's own namespace."},"wallet_id":{"type":["string","null"],"description":"The wallet named on the create request, or null. Written once at creation and never rewritten: neither state transition on this consent touches the column.\n\nIt is a label, not routing. A statement refresh copies this value onto every event it ingests, and nothing downstream reads it: reconciliation resolves a wallet from the funding source behind an event's `connection_id`, and the events this family publishes carry no such key. Binding a consent to a wallet therefore does not, on its own, put the ingested statement into that wallet's reconciliation."},"status":{"type":"string","enum":["pending","authorised","revoked","expired","consumed"],"description":"`authorised` carries the British spelling, which is the column's own and not a typo. `revoked`, `expired` and `consumed` are terminal: no transition leaves them. Read \"is this consent usable now\" from `status` being `authorised`, never from `revoked_at` being null."},"scopes":{"type":"array","items":{"type":"string"},"description":"The OFB scopes the consent was opened for."},"bank_consent_id":{"type":["string","null"],"description":"The bank's own identifier for the consent, as returned when it was opened."},"authorisation_url":{"type":["string","null"],"description":"The URL the consumer opens to authorise the consent at their bank."},"token_expires_at":{"type":["string","null"],"format":"date-time","description":"Expiry of the bank access token. The token references themselves are never returned by this API."},"created_at":{"type":"string","format":"date-time"},"authorized_at":{"type":["string","null"],"format":"date-time","description":"American spelling here, unlike `status: \"authorised\"`; both mirror their own column names. Stamped in the same statement as the transition into `authorised`."},"expires_at":{"type":["string","null"],"format":"date-time","description":"Mirror of the bank-side consent expiry, stamped when the consent is opened."},"revoked_at":{"type":["string","null"],"format":"date-time"},"consumed_at":{"type":["string","null"],"format":"date-time"},"metadata":{"type":"object","additionalProperties":{}}},"required":["id","bank_id","consumer_id","wallet_id","status","scopes","bank_consent_id","authorisation_url","token_expires_at","created_at","authorized_at","expires_at","revoked_at","consumed_at","metadata"]}}}},"404":{"description":"Not Found. No consent with this id in the caller's project. A consent belonging to another project in the same organization answers here too.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/cart/meli/connect/start":{"post":{"summary":"Open a Mercado Livre login for a shopper (connect-once, step 1 of 2)","description":"Opens a hosted browser on a fresh persistent context, points it at mercadolivre.com.br, and returns a live-view URL. The SHOPPER opens that URL and logs into their own Mercado Livre account, 2FA included. The login lands in the context, not here.\n\nWHAT IT BUYS YOU LATER. Once the matching finish call has run, a Mercado Livre checkout made for that consumer drives the browser on THEIR account instead of a shared one. Until then this call has changed nothing a shopper can see.\n\nBEFORE this call returns, the server writes a pending row binding the session it just opened to the context it issued and to the calling org and project. That row is what makes a finish possible, it is single use, and it stops being finishable 30 minutes after the start. A shopper who never logs in costs nothing: the row simply expires.\n\nTHE REQUEST BODY IS NOT READ. The handler passes only the merchant and the caller's own authenticated context, so there is no field to send and none to get wrong. One caveat, measured against this repository's Fastify 5 rather than assumed: a request that sets `Content-Type: application/json` and then sends an EMPTY or malformed body is refused 400 by the framework's body parser before the handler runs, with Fastify's default error body (`statusCode`, `code`, `error`, `message`, where `code` is `FST_ERR_CTP_EMPTY_JSON_BODY` or `FST_ERR_CTP_INVALID_JSON_BODY`). That is a different shape from the `{ error, message }` below and its `code` is in neither enum. Sending `{}`, or sending no body and no content-type, reaches the handler.\n\nSLOW BY CONSTRUCTION. The call waits on a real browser starting up and loading the site; the worker client allows it at least 90 seconds. Set a client timeout to match rather than retrying into a second abandoned session.\n\nScope: `connections:write`, not one of the `cart:*` scopes. Starting a login is the same act as beginning any other authorization dance that ends in a stored credential, and a key allowed only to build carts should not be able to begin one that later spends on a person's account.\n\nA 502 does not tell you how far the attempt got. The hosted browser call and the pending-row insert are inside the same try, so the code is identical whether the worker never answered or the row failed to write after a browser session was already open. When it is the second, the open session is left to time out on its own. Retrying is safe: every start mints a new context and a new session.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"merchant":{"type":"string","enum":["meli"]},"context_id":{"type":"string","description":"The persistent browser context the login is about to be written into, fresh for this connect. Bind it to nothing yourself: the server remembers which context it issued for this session and later checkouts resolve it from the stored mapping."},"session_id":{"type":"string","description":"The live browser session. It is also the handle for the finish call, and the server keeps a single-use pending row for it. Nobody else can present it: a finish is refused unless the row was started by the same org and project."},"live_view_url":{"type":"string","description":"The interactive URL a HUMAN opens to log into their own marketplace account. MAY COME BACK AS AN EMPTY STRING, and the call still answers 200: the worker builds it as `debuggerFullscreenUrl || debuggerUrl || \"\"` from the hosted browser's debug endpoint, and nothing between there and the response checks it. An empty value means the start failed in practice even though it succeeded on the wire. Discard the session_id and start another connect; there is nothing for the shopper to open."}},"required":["merchant","context_id","session_id","live_view_url"]}}}},"502":{"description":"The hosted browser worker refused or failed, or the pending session could not be recorded. Not terminal: retry mints a new context and session.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["meli_connect_failed"]},"message":{"type":"string","description":"Free text for an operator, not a code to branch on. On a 502 it relays the underlying failure, which may be the browser worker's own status line truncated to 160 characters, or a database error from the pending-session insert."}},"required":["error","message"]}}}},"503":{"description":"No browser worker is configured for this deployment, so no connect can be started at all. Terminal for the caller: retrying cannot fix it, and it is an operator's configuration to supply.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["browser_worker_unconfigured"]},"message":{"type":"string","description":"Free text for an operator, not a code to branch on. On a 502 it relays the underlying failure, which may be the browser worker's own status line truncated to 160 characters, or a database error from the pending-session insert."}},"required":["error","message"]}}}}}}},"/v1/cart/ifood/connect/start":{"post":{"summary":"Open an iFood login for a shopper (connect-once, and iFood cannot be bought from)","description":"Same mechanism as the Mercado Livre start: a hosted browser on a fresh persistent context, opened on ifood.com.br, and a live-view URL the SHOPPER logs into (phone number plus SMS code).\n\nREAD THIS BEFORE ASKING ANYONE TO LOG IN. iFood has NO checkout on this API, in live or in test. That is why every response below carries `checkout_supported: false` and a `note` saying so in words: the disclosure has to reach the person who is about to type an SMS code, and an agent relaying only the final result of a connect would otherwise never see it. The refusal on the checkout side is not a capability gap that might resolve itself on retry either. `runStoreCheckout` keeps iFood in a connect-only set and refuses with 400 `merchant_not_supported` before it looks at any infrastructure, telling the caller not to retry. The login is stored for a capability that does not exist yet.\n\nBEFORE this call returns, the server writes a pending row binding the session it just opened to the context it issued and to the calling org and project. That row is what makes a finish possible, it is single use, and it stops being finishable 30 minutes after the start. A shopper who never logs in costs nothing: the row simply expires.\n\nTHE REQUEST BODY IS NOT READ. The handler passes only the merchant and the caller's own authenticated context, so there is no field to send and none to get wrong. One caveat, measured against this repository's Fastify 5 rather than assumed: a request that sets `Content-Type: application/json` and then sends an EMPTY or malformed body is refused 400 by the framework's body parser before the handler runs, with Fastify's default error body (`statusCode`, `code`, `error`, `message`, where `code` is `FST_ERR_CTP_EMPTY_JSON_BODY` or `FST_ERR_CTP_INVALID_JSON_BODY`). That is a different shape from the `{ error, message }` below and its `code` is in neither enum. Sending `{}`, or sending no body and no content-type, reaches the handler.\n\nSLOWER THAN THE MERCADO LIVRE START. iFood sits behind bot management, and the worker deliberately waits about 15 seconds for that challenge to clear before handing over the live view, so the shopper does not open it on an interstitial. The worker client allows the call at least 120 seconds.\n\nScope: `connections:write`, not one of the `cart:*` scopes. Starting a login is the same act as beginning any other authorization dance that ends in a stored credential, and a key allowed only to build carts should not be able to begin one that later spends on a person's account.\n\nA 502 does not tell you how far the attempt got. The hosted browser call and the pending-row insert are inside the same try, so the code is identical whether the worker never answered or the row failed to write after a browser session was already open. When it is the second, the open session is left to time out on its own. Retrying is safe: every start mints a new context and a new session.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"merchant":{"type":"string","enum":["ifood"]},"context_id":{"type":"string","description":"The persistent browser context the login is about to be written into, fresh for this connect. Bind it to nothing yourself: the server remembers which context it issued for this session and later checkouts resolve it from the stored mapping."},"session_id":{"type":"string","description":"The live browser session. It is also the handle for the finish call, and the server keeps a single-use pending row for it. Nobody else can present it: a finish is refused unless the row was started by the same org and project."},"live_view_url":{"type":"string","description":"The interactive URL a HUMAN opens to log into their own marketplace account. MAY COME BACK AS AN EMPTY STRING, and the call still answers 200: the worker builds it as `debuggerFullscreenUrl || debuggerUrl || \"\"` from the hosted browser's debug endpoint, and nothing between there and the response checks it. An empty value means the start failed in practice even though it succeeded on the wire. Discard the session_id and start another connect; there is nothing for the shopper to open."},"checkout_supported":{"type":"boolean","enum":[false],"description":"Always present and always false on this route. Its absence on the Mercado Livre response is the signal that the merchant CAN be bought from; do not read a missing field as false."},"note":{"type":"string","description":"A fixed disclosure sentence, held as a constant in `cart/connect-core.ts` and repeated verbatim by the finish call: iFood checkout is not supported yet, the login is stored for a future capability, and no purchase can be made on iFood today in live or in test."}},"required":["merchant","context_id","session_id","live_view_url","checkout_supported","note"]}}}},"502":{"description":"The hosted browser worker refused or failed, or the pending session could not be recorded. Not terminal: retry mints a new context and session.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["ifood_connect_failed"]},"message":{"type":"string","description":"Free text for an operator, not a code to branch on. On a 502 it relays the underlying failure, which may be the browser worker's own status line truncated to 160 characters, or a database error from the pending-session insert."}},"required":["error","message"]}}}},"503":{"description":"No browser worker is configured for this deployment, so no connect can be started at all. Terminal for the caller: retrying cannot fix it, and it is an operator's configuration to supply.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["browser_worker_unconfigured"]},"message":{"type":"string","description":"Free text for an operator, not a code to branch on. On a 502 it relays the underlying failure, which may be the browser worker's own status line truncated to 160 characters, or a database error from the pending-session insert."}},"required":["error","message"]}}}}}}},"/v1/cards/{id}":{"get":{"externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979: canonical path and alias window"},"summary":"Read an issued card, or the mint flow that is producing one","description":"ONE PATH, TWO RESOURCES, chosen by the id's prefix. An id beginning `imf_` is a MINT FLOW and the response is the flow. Anything else is looked up as a card row and the response is the card. There is no query parameter and no header that changes this: the prefix is the whole dispatch, so a client has to be ready for both bodies on one operation.\n\nA flow that reached `issued` and already points at a card row answers with the flow PLUS a `card` key. No other flow state carries that key at all. So the polling loop after a mint is: read the flow id, and when `state` is `issued`, read `card`.\n\nSCOPING. Both lookups carry the caller's org and project in the WHERE clause. A card or flow belonging to a sibling project of the same org is indistinguishable from one that was never created: both are 404, with the same code.\n\nNO CARD-NUMBER MATERIAL crosses this surface. `last4` and, once ingest lands, `token_id` are what a card exposes; the number itself lives only in the cardholder-data environment and is never part of this response.\n\nThe canonical path. `GET /v1/issuer/cards/{id}` is the same handler at the path that shipped first, kept for two releases and not described in this document.","parameters":[{"schema":{"type":"string","description":"A card row id, or an `imf_` prefixed mint flow id. The prefix selects which."},"required":true,"description":"A card row id, or an `imf_` prefixed mint flow id. The prefix selects which.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"id":{"type":"string","description":"CodeSpar's own id for the card row. This is what `/v1/cards/{id}` resolves."},"issuer":{"type":"string","enum":["pomelo","bridge"],"description":"Closed from two sides: `IssuerId` in `@codespar-enterprise/issuer` and `CHECK (issuer IN ('pomelo', 'bridge'))` on `issuer_cards` (migration 0118)."},"card_id":{"type":"string","description":"The issuer's own identifier for the card, as it was returned to us."},"last4":{"type":"string","description":"The last four digits. No other card-number material exists on this surface."},"network":{"type":"string","enum":["visa","mastercard"],"description":"`CHECK (network IN ('visa', 'mastercard'))`, migration 0118."},"status":{"type":"string","enum":["active","blocked","canceled"],"description":"`IssuerCardStatus`, and `CHECK (status IN ('active', 'blocked', 'canceled'))` in 0118."},"token_id":{"type":["string","null"],"description":"The card's token in the cardholder-data environment. Null until ingest lands, which is its own step after the card exists, so a freshly issued card can be readable here with no token yet."},"created_at":{"type":"string","format":"date-time"}},"required":["id","issuer","card_id","last4","network","status","token_id","created_at"]},{"type":"object","properties":{"flow_id":{"type":"string","description":"`imf_` prefixed. The row it names is keyed by the mint call's `Idempotency-Key`, or by a server-minted one when that header was absent."},"state":{"type":"string","enum":["pending","issued","revoked","failed"],"description":"`MintFlowState`, and `CHECK (state IN ('pending', 'issued', 'revoked', 'failed'))` in 0118."},"issuer":{"type":"string","enum":["pomelo","bridge"]},"reason":{"type":["string","null"],"description":"Why the flow is parked, when it is parked. Null otherwise."},"kyc_url":{"type":["string","null"],"description":"Where the holder finishes identity verification, when the issuer asked for one. Null otherwise."},"created_at":{"type":"string","format":"date-time"},"card":{"type":["object","null"],"properties":{"id":{"type":"string","description":"CodeSpar's own id for the card row. This is what `/v1/cards/{id}` resolves."},"issuer":{"type":"string","enum":["pomelo","bridge"],"description":"Closed from two sides: `IssuerId` in `@codespar-enterprise/issuer` and `CHECK (issuer IN ('pomelo', 'bridge'))` on `issuer_cards` (migration 0118)."},"card_id":{"type":"string","description":"The issuer's own identifier for the card, as it was returned to us."},"last4":{"type":"string","description":"The last four digits. No other card-number material exists on this surface."},"network":{"type":"string","enum":["visa","mastercard"],"description":"`CHECK (network IN ('visa', 'mastercard'))`, migration 0118."},"status":{"type":"string","enum":["active","blocked","canceled"],"description":"`IssuerCardStatus`, and `CHECK (status IN ('active', 'blocked', 'canceled'))` in 0118."},"token_id":{"type":["string","null"],"description":"The card's token in the cardholder-data environment. Null until ingest lands, which is its own step after the card exists, so a freshly issued card can be readable here with no token yet."},"created_at":{"type":"string","format":"date-time"}},"required":["id","issuer","card_id","last4","network","status","token_id","created_at"],"description":"PRESENT ONLY when `state` is `issued` AND the flow already points at a card row. In every other case the key is absent rather than null. Null inside that case means the card row did not resolve under this org and project."}},"required":["flow_id","state","issuer","reason","kyc_url","created_at"]}],"description":"The card, when the id is a card id; the mint flow, when the id begins `imf_`."}}}},"404":{"description":"`issuer_card_unknown` for both misses, with different messages: `mint flow not found` for an `imf_` id and `issuer card not found` otherwise. One code, so a client cannot tell a wrong id from another project's id by reading the code.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["issuer_card_unknown"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/counterparties/{id}":{"get":{"externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979: canonical path and alias window"},"summary":"Read one counterparty","description":"The same row the list returns, by id.\n\nA counterparty that belongs to another org, or to a sibling project of the same org, is indistinguishable from one that does not exist: both are 404. Counterparty ids are unique per project and not globally, so the same id can name a different company in another project.\n\nThe canonical path. `GET /v1/commerce-memory/counterparties/{id}` is the deprecated alias of this operation, described in this document under that path and kept for two releases. The two paths are the same handler with the same required scope, so switching one for the other changes nothing about the request, the response or the credential.","parameters":[{"schema":{"type":"string","description":"`cp_` prefixed."},"required":true,"description":"`cp_` prefixed.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`cp_` prefixed."},"kind":{"type":"string","enum":["supplier","customer","agent_peer"],"description":"`CHECK (kind IN ('supplier', 'customer', 'agent_peer'))`, migration 0050."},"name":{"type":"string"},"identifier":{"type":["string","null"],"description":"Null when the counterparty was registered without one."},"identifier_kind":{"type":["string","null"],"enum":["cnpj","cpf","email","phone","wallet_address","other"],"description":"`CHECK (identifier_kind IN ('cnpj', 'cpf', 'email', 'phone', 'wallet_address', 'other'))`, migration 0050. Null exactly when `identifier` is null: the pair is held together by `commerce_counterparties_identifier_pair`, a table constraint, so one without the other cannot be stored."},"metadata":{"type":"object","additionalProperties":{}},"created_at":{"type":"string","format":"date-time"},"last_seen_at":{"type":"string","format":"date-time","description":"Bumped by a re-registration of the same identifier and by every interaction recorded against this row."}},"required":["id","kind","name","identifier","identifier_kind","metadata","created_at","last_seen_at"]}}}},"404":{"description":"`not_found`, including for a counterparty outside the caller's project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/servers/{id}/auth-schema":{"get":{"externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979: canonical path and alias window"},"summary":"Read the credential form a provider needs, without reading any credential","description":"What to ask an operator for before connecting this provider, and where the request will go once connected. It NEVER returns a stored secret: the vault is write-only from this side, and `fields` describes inputs to collect, not values that exist.\n\nAN EMPTY `fields` DOES NOT MEAN NOTHING TO DO, and four auth types produce one. `oauth` collects nothing here because the browser leg starts at `POST /v1/connections/start` instead. `none` needs no credential. And `jwt_ecdsa` and `cdp` reach no field-building branch at all, so they come back empty while still needing operator-issued material: read that pair as unsupported by this form rather than as ready to connect. The other five all return at least one field, `cert` included.\n\n`base_url` IS ENVIRONMENT-RESOLVED against the credential in hand: a test key sees the provider's test host when the catalog declares one, and the live host otherwise. It is the empty string when the provider has no endpoint row, which is a catalog gap rather than a value to dial.\n\nThis is the path that shipped first; `GET /v1/providers/{slug}/auth-schema` is the same handler at the canonical path (ent#979) and is not yet described in this document, so the full response is described HERE and nothing is withheld until you switch paths.\n\nThe visibility gate in front of this operation only hides GENERATED providers (ids prefixed `gen_`) that belong to another project. Any id without that prefix passes the gate, including an id that names no provider at all. Past the gate, a provider that is genuinely absent from the catalog is a 404 as well, so this operation does distinguish a real provider from an invented one.","parameters":[{"schema":{"type":"string","description":"The provider id, which is also the catalog row's primary key."},"required":true,"description":"The provider id, which is also the catalog row's primary key.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"server_id":{"type":"string"},"auth_type":{"type":"string","description":"LEFT OPEN ON PURPOSE, and the reason is measured rather than cautious. The column admits nine values today — `api_key`, `path_secret`, `oauth`, `cert`, `none`, `hmac_signed`, `jwt_ecdsa`, `two_header`, `cdp` — under a CHECK constraint that has been widened four times (migrations 0064, 0066, 0074, 0091, over the six of 0026). The handler casts the column straight into this field, so the set of values a client can receive is the DB's, not any narrower published enum. Closing this to six would describe a wire that already carries more."},"environment":{"type":"string","enum":["live","test"],"description":"Taken from the calling credential, and what `base_url` was resolved against."},"base_url":{"type":"string","description":"The host a call to this provider will be sent to, in the caller's environment. Empty string when the provider has no endpoint row."},"oauth_authorize_url":{"type":["string","null"],"description":"Where the browser leg starts, environment-resolved the same way. Null for every auth type other than `oauth`, and also null for an `oauth` provider with no configuration row."},"fields":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","description":"The key to submit this value under when the connection is created. For a header-bearing field it is derived from the catalog's ref name, not from the header."},"kind":{"type":"string","enum":["api_key","path_secret","header","cert"],"description":"What input to render. These four are the whole set the handler can emit: it builds `AuthSchemaField[]`, so a fifth kind would not compile. `hmac_signed` reuses `path_secret` and `two_header` reuses `header` rather than each having its own."},"label":{"type":"string","description":"Human-readable label. For a header field it is the header name itself."},"header_name":{"type":"string","description":"Present only on `header` fields, naming the HTTP header the value is stamped under."}},"required":["name","kind","label"]},"description":"In the order the form should render, which is the catalog's declared order: the visible field before the masked one for the multi-field types."}},"required":["server_id","auth_type","environment","base_url","oauth_authorize_url","fields"]}}}},"404":{"description":"The provider is not visible to this project. Not the `apiError` envelope and not `{ error: \"not_found\" }`: a third shape, built at the route.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["server_unknown"]},"server_id":{"type":"string","description":"Echoes the id from the path."}},"required":["error","server_id"]}}}}}}},"/v1/servers/{id}/tools":{"get":{"summary":"List the tools a provider exposes","description":"Every tool registered for this provider, ordered by name, with no limit and no cursor: `total` is the length of `tools` and the two cannot disagree.\n\nAN UNKNOWN PROVIDER IS 200 HERE, NOT 404, and it is the one thing worth knowing about this operation. The visibility gate in front of this operation only hides GENERATED providers (ids prefixed `gen_`) that belong to another project. Any id without that prefix passes the gate, including an id that names no provider at all. There is no second check after it, so an id that names nothing reaches the tool query, matches no rows, and comes back as a well-formed empty list with that id echoed in `server_id`. An empty `tools` therefore means either a real provider with nothing registered or a provider that does not exist, and this response cannot tell you which. The 404 below is reachable only for another project's generated provider.","parameters":[{"schema":{"type":"string","description":"The provider id."},"required":true,"description":"The provider id.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"server_id":{"type":"string","description":"Echoes the id from the path, whether or not it named anything."},"total":{"type":"integer","description":"Always equal to the length of `tools`."},"tools":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","description":"The tool name, and the sort key of this list."},"description":{"type":["string","null"],"description":"Null when the catalog carries none."}},"required":["name","description"]}}},"required":["server_id","total","tools"]}}}},"404":{"description":"The provider is not visible to this project. Not the `apiError` envelope and not `{ error: \"not_found\" }`: a third shape, built at the route.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["server_unknown"]},"server_id":{"type":"string","description":"Echoes the id from the path."}},"required":["error","server_id"]}}}}}}},"/v1/servers/{id}/verify-connection":{"post":{"externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979: canonical path and alias window"},"summary":"Ask the provider whether this project's stored credential still works","description":"Issues ONE safe read against the provider using the credential this project has stored for it, and reports what came back. POST because it leaves the process and spends a provider call, not because it changes anything here: no state of yours is altered by it. It takes NO REQUEST BODY, and anything sent is ignored.\n\nWHICH READ depends on the provider. A handful ship a bespoke recipe, and those are the ones that can return an `account` summary. Everything else re-runs the provider's declared functional probe, the same authenticated read used to confirm a sandbox works when it is first set up; that path returns no `account`. A provider with neither answers 501.\n\nREAD THE STATUS, NOT THE BODY, TO DECIDE WHAT TO DO. Seven failure codes map onto seven distinct statuses, and only ONE of them is worth retrying unchanged (502). 401 is a verdict on the credential; 424 means there is no connection to test yet; 409 means the host redirected and never judged the credential, so a retry gets the same non-answer; 501 and 503 are configuration, not weather.\n\nTHE 502 IS WIDER THAN ITS NAME. `provider_unreachable` is what every non-2xx that is not a redirect and not a 401 or 403 becomes, so a provider answering 400, 404, 409, 422 or 429 arrives here as 502 with the provider's own status in `status`. An agent that retries every 502 will retry a 429 sensibly and a 400 forever. Read `status` before deciding.\n\nTWO DIFFERENT BODIES SHARE THE 404, from two different lines: the visibility gate's `{ error, server_id }`, and the verify outcome's `{ ok: false, provider, error }` when the credential resolver finds no such provider in the catalog. Both are described below.\n\nThis is the path that shipped first; `POST /v1/providers/{slug}/verify-connection` is the same handler at the canonical path (ent#979) and is not yet described in this document.\n\nThe visibility gate in front of this operation only hides GENERATED providers (ids prefixed `gen_`) that belong to another project. Any id without that prefix passes the gate, including an id that names no provider at all.","parameters":[{"schema":{"type":"string","description":"The provider id."},"required":true,"description":"The provider id.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]},"provider":{"type":"string","description":"The provider id from the path."},"account":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"extras":{"type":"object","additionalProperties":{},"description":"Whatever the provider's recipe chose to surface, keyed by that provider's own names."}},"description":"ABSENT on the probe path, and absent on the recipe path whenever the recipe found nothing it recognised in the response. Present means the read reached a real account; absent does NOT mean it did not."},"latency_ms":{"type":"integer","description":"Wall-clock milliseconds around the outbound call."}},"required":["ok","provider","latency_ms"]}}}},"401":{"description":"`provider_rejected`. The provider answered 401 or 403: it looked at the credential and said no. Retrying unchanged will fail the same way.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[false]},"provider":{"type":"string","description":"The provider id from the path."},"error":{"type":"string","enum":["provider_rejected"]},"status":{"type":"integer","description":"The provider's own status, 401 or 403."},"latency_ms":{"type":"integer","description":"Wall-clock milliseconds around the outbound call."},"detail":{"type":"string","description":"The provider's OWN response body, truncated to 256 characters, on the recipe path. On the probe path it is the probe's error text instead, with the credential masked out. This is one of only two codes where `detail` carries provider text."}},"required":["ok","provider","error","status","latency_ms"]}}}},"404":{"description":"Two shapes under one status. The route's own gate answers `{ error, server_id }` before any work happens; the credential resolver answers the verify outcome's `server_unknown` when the provider is absent from the catalog. Parse the union.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"string","enum":["server_unknown"]},"server_id":{"type":"string","description":"Echoes the id from the path."}},"required":["error","server_id"]},{"type":"object","properties":{"ok":{"type":"boolean","enum":[false]},"provider":{"type":"string","description":"The provider id from the path."},"error":{"type":"string","enum":["server_unknown"]}},"required":["ok","provider","error"]}]}}}},"409":{"description":"`redirect_not_followed`. The configured host answered 3xx. The probe is issued with redirects disabled on purpose, so the credential was never judged by anyone: this is the absence of a verdict, not a negative one. Retrying cannot change it; the host or the catalog entry has to.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[false]},"provider":{"type":"string","description":"The provider id from the path."},"error":{"type":"string","enum":["redirect_not_followed"]},"status":{"type":"integer","description":"The 3xx the host answered with."},"latency_ms":{"type":"integer","description":"Wall-clock milliseconds around the outbound call."}},"required":["ok","provider","error","status","latency_ms"]}}}},"424":{"description":"`not_connected`. There is nothing to verify: either no active connection exists for this project and provider, or one exists and its stored credential did not resolve to a value. Connect the provider, or reconnect it.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[false]},"provider":{"type":"string","description":"The provider id from the path."},"error":{"type":"string","enum":["not_connected"]},"detail":{"type":"string","description":"A CodeSpar-authored diagnostic naming which of the two cases happened, NOT provider text: `No active connection found for this project + server.`, `Vault returned no value for the connection's credential ref.`, or a sentence naming the specific credential parts that failed to resolve."},"latency_ms":{"type":"integer","description":"Present only when the probe path produced this outcome, since only that path timed anything. Absent on the resolver's own branches."},"authType":{"type":"string","description":"The provider's declared auth mechanism, on the branch where no connection exists at all. It is what tells a caller whether connecting is even self-serve: `cert`, `hmac_signed`, `jwt_ecdsa` and `two_header` all need material an operator has to obtain out of band."},"hint":{"type":"string","description":"Added by the route to this code only, so a stuck agent has a next move rather than a connect dead end: it points at the pre-connected sandbox Pix rails."},"alternative":{"type":"string","description":"The meta-tool to call instead."},"caveat":{"type":"string","description":"Added only for a `cert` provider in the test environment, where the rail cannot run at all."}},"required":["ok","provider","error","hint","alternative"]}}}},"501":{"description":"`verify_unsupported`. This provider ships neither a bespoke recipe nor a declared probe, so there is no safe read to issue. Nothing is wrong with the credential and nothing about it has been learned; the first real call is where authentication errors will surface.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[false]},"provider":{"type":"string","description":"The provider id from the path."},"error":{"type":"string","enum":["verify_unsupported"]},"detail":{"type":"string","description":"A CodeSpar-authored sentence naming this provider, not provider text. No outbound call was made on this branch, which is why there is no `status` and no `latency_ms`."}},"required":["ok","provider","error"]}}}},"502":{"description":"`provider_unreachable`. EVERY non-2xx that is not a redirect and not a 401 or 403 lands here, together with network failures and the per-call timeout (10 seconds by default, deployment-configurable). That includes a provider's 400, 404, 409, 422 and 429. Only some of those are worth retrying, and `status` is the field that separates them.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[false]},"provider":{"type":"string","description":"The provider id from the path."},"error":{"type":"string","enum":["provider_unreachable"]},"status":{"type":"integer","description":"The provider's own status, when there WAS a response. ABSENT when the call never completed — connection failure, DNS, or the timeout — which is the one case where a plain retry is the right move."},"latency_ms":{"type":"integer","description":"Wall-clock milliseconds around the outbound call."},"detail":{"type":"string","description":"The provider's OWN response body, truncated to 256 characters, when a response arrived. When none did, the network error's message instead. The second of the two codes whose `detail` can carry provider text."}},"required":["ok","provider","error","latency_ms"]}}}},"503":{"description":"`endpoint_missing`. The provider is in the catalog but the row that says where to reach it is missing or contradicts how the connection was stored. A catalog or connection problem on our side, not a verdict on the credential and not a provider outage.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[false]},"provider":{"type":"string","description":"The provider id from the path."},"error":{"type":"string","enum":["endpoint_missing"]},"detail":{"type":"string","description":"ABSENT on the resolver's branch, which sends the three keys above and nothing else. Present only where a handler branch wrote a CodeSpar-authored sentence describing the mismatch. Never provider text."}},"required":["ok","provider","error"]}}}}}}},"/v1/funding-sources/{id}":{"get":{"summary":"Read one consumer funding source","description":"One consumer rail: which provider holds it, which currency it authorizes, and whether it is still usable. Requires the `consumers:fund` scope.\n\nRESOLUTION IS SCOPED TO THE ORGANIZATION AND THE PROJECT the credential authenticates for. A funding source held by a SIBLING project in the same organization answers 404 here, indistinguishable from one that never existed. Note that the sibling operations in this group do not all draw the line in the same place: the consent-record read below is scoped to the organization only.\n\nNO LIVE BALANCE, and no query parameter asks for one. `balance_minor` is always null; the balance lives in the ledger.\n\nNo debit credential leaves. The row's provider token is an opaque vault pointer and is not in the response — but `metadata` is echoed raw and can carry the consumer's document number, so read its own note before logging the body.","parameters":[{"schema":{"type":"string","description":"The funding source id."},"required":true,"description":"The funding source id.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`cfs_`- or `fs_celcoin_`-prefixed, depending on which writer created it."},"project_id":{"type":"string","description":"Always a string here, never null. Rows the 0129 backfill could not attribute carry `project_id IS NULL`, and the resolving predicate is a strict `=` against the credential's project, which no NULL satisfies — so an unattributed row answers 404 on this path rather than returning with a null."},"consumer_id":{"type":"string","description":"The consumer, in the caller's own namespace."},"rail":{"type":"string","description":"An OPEN string, and deliberately not an enum here. `consumer_funding_sources` constrains it only with `CHECK (length(rail) > 0)`, and the writers do not agree on a closed set: `celcoin-onboarding.ts` writes the literal `pix-celcoin`, `routes/consents.ts` writes one of its own five-value `RAILS` list on the single-rail path, and its multi-slot path writes `methodToRail(slot.rail)` — where the slot's `rail` is validated as `z.string().min(1).max(40)` and `methodToRail` returns its argument unchanged when no mapping matches. So a caller who opened a consent with an unmapped slot rail reads that same string back here.\n\nThe values a reader should expect in practice: `pix-celcoin`, `pix-consent`, `card-token`, `ted-debit-auth`, `usd-ach-debit`, `usdc-onchain`. Match on those and tolerate anything else."},"currency":{"type":"string","enum":["BRL","USD","MXN","COP","ARS","USDC","BRLA"],"description":"Closed by `consumer_funding_sources_currency_check`. That constraint NAME appears in exactly one file under `packages/api/src/migrations/`, so nothing later drops, relaxes or replaces it — the instrument is the name, not a list of the migrations that came after, because the list is what rots while the conclusion holds."},"provider":{"type":"string","description":"The provider behind the rail. Derived, not chosen: `celcoin` from the onboarding writer, and `rail.split(\"-\")[0]` from the consent writer, so it inherits the same openness `rail` has."},"display_label":{"type":["string","null"],"description":"Caller-supplied label, or null."},"status":{"type":"string","enum":["active","revoked","expired"],"description":"Closed by `consumer_funding_sources_status_check`, whose name likewise appears in exactly one migration file. Read usability from `status` being `active`. A companion constraint keeps `revoked_at` and `status = 'revoked'` in step, so they cannot disagree."},"pix_key":{"type":["string","null"],"description":"Lifted out of `metadata.pixKey` when that key holds a string, else null. It is the DICT entry registered for the account, not a credential."},"pix_key_type":{"type":["string","null"],"description":"Lifted out of `metadata.pixKeyType`, else null."},"balance_minor":{"type":["object","null"],"enum":[null],"description":"ALWAYS null on this operation, and present so a caller does not have to tell an absent field from an unknown balance. Live balance left the canonical surface with D4: the balance lives in the ledger. This read is new enough to have no legacy alias that could be relying on the old behaviour, so it never calls the provider."},"created_at":{"type":"string","format":"date-time"},"expires_at":{"type":["string","null"],"format":"date-time"},"revoked_at":{"type":["string","null"],"format":"date-time","description":"Non-null exactly when `status` is `revoked`; the table enforces the pair."},"metadata":{"type":"object","additionalProperties":{},"description":"The raw column, echoed with nothing stripped — plan for personal data in it. `provisionCelcoinFundingSource` writes `account`, `branch`, `provider` and, when the onboarding carried one, `documentNumber`, which is the consumer's CPF or CNPJ. Treat this object as regulated personal data at rest and in logs, and do not echo it to an end user.\n\nWhat is NOT in it is the debit credential: that lives in the vault, and the row holds only an opaque `provider_token_ref` pointer, which this response does not carry."}},"required":["id","project_id","consumer_id","rail","currency","provider","display_label","status","pix_key","pix_key_type","balance_minor","created_at","expires_at","revoked_at","metadata"]}}}},"404":{"description":"Not Found. No funding source with this id in the caller's project. A row belonging to a sibling project, and a row the 0129 backfill left unattributed, both answer here.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/consent-records/{id}":{"get":{"summary":"Read one consent record","description":"One row of the consent log: the moment a consumer authorized a specific agent to debit a specific funding source up to a specific cap. Requires the `consents:read` scope.\n\nAPPEND-ONLY EVIDENCE. `consumer_consents` is the LGPD audit log — rows are never rewritten and never deleted, and a revocation is a new row with `kind = 'revoke'`. That is what lets a revocation be replayed from the log alone, and it is why this read returns exactly what the listing returns: there is no derived state for a point read to add.\n\nRESOLUTION IS SCOPED TO THE ORGANIZATION ONLY — the query predicates on `org_id` and the id, with no project term. A consent record created under a sibling project in the same organization IS readable with this credential. The funding-source read in this same group is narrower, so do not carry an assumption across the two.\n\nNO SIGNATURE MATERIAL LEAVES. The row carries `payload_hash` and `hmac_signature`; both are dropped before the body is built. What crosses the wire is `hmac_secret_version` — the stamp that says which secret signed it — and its note explains why that stamp is what makes secret rotation safe rather than destructive.\n\nPERSONAL DATA. `ip_address` is the consumer's, recorded because a consent event has to be replayable with its origin.","parameters":[{"schema":{"type":"string","description":"The consent record id."},"required":true,"description":"The consent record id.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`ccsnt_`-prefixed on rows the mandate-lifecycle writer creates."},"consumer_id":{"type":"string"},"funding_source_id":{"type":["string","null"],"description":"The `consumer_funding_sources` row this consent authorizes debits against. Nullable on the column, and required by `consumer_consents_grant_completeness` on `kind = 'grant'` rows: a grant must declare what it authorizes, while `revoke` and `amend` reference the original grant."},"agent_id":{"type":["string","null"],"description":"The agent the consumer authorized, when the row names one."},"kind":{"type":"string","enum":["grant","revoke","amend"],"description":"Closed by `consumer_consents_kind_check`, whose name appears in exactly one migration file. The log is append-only: a revocation is a NEW row with `kind = 'revoke'`, never a rewrite of the grant, which is what makes a revocation replayable from the log alone."},"hmac_secret_version":{"type":"integer","description":"Which version of the consumer's HMAC secret signed this row. This is the field that makes rotation safe, and the half that is usually left out when the rule is quoted.\n\nRotating a consumer's secret invalidates FUTURE mandates only, and the reason is here rather than in the rotation: the verifier resolves the secret by `(consumer, version)` and the mandate itself carries the version it was signed under, so a mandate signed under the prior version still resolves the prior secret and still verifies. Rotation marks the old row and inserts version+1; it deletes nothing. Without the version stamp the sentence would mean the opposite — that rotating breaks the audit trail."},"cap_minor":{"type":["string","null"],"description":"A STRING, not a number. The column is `bigint` and the driver hands bigints back as strings; the handler passes the value straight through. Parse it as an integer in the row's `currency`, and do not assume it survives a float. Null on `revoke` / `amend` rows."},"per_tx_cap_minor":{"type":["string","null"],"description":"Same `bigint`-as-string treatment as `cap_minor`."},"currency":{"type":["string","null"],"enum":["BRL","USD","MXN","COP","ARS","USDC","BRLA"],"description":"Closed by `consumer_consents_currency_check` (name in exactly one migration file), which admits NULL as well as the seven values. Non-null on `grant` rows, which the completeness constraint requires to declare one."},"purpose":{"type":["string","null"],"description":"The purpose the consumer signed. Non-null on `grant` rows."},"ip_address":{"type":["string","null"],"description":"The consumer's IP at signing time, from an `inet` column — personal data, held because a consent event has to be replayable with timestamp and origin. `user_agent` is recorded alongside it and is NOT returned."},"signed_at":{"type":"string","format":"date-time","description":"When the consent event happened. The listing orders by this."},"expires_at":{"type":["string","null"],"format":"date-time"},"metadata":{"type":"object","additionalProperties":{},"description":"Defaults to `{}`. Rows written by the mandate-lifecycle handler carry the transition instead: `action`, `from`, `to`, and `reason` when the caller gave one."}},"required":["id","consumer_id","funding_source_id","agent_id","kind","hmac_secret_version","cap_minor","per_tx_cap_minor","currency","purpose","ip_address","signed_at","expires_at","metadata"]}}}},"404":{"description":"Not Found. No consent record with this id in the caller's organization.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/account-applications/{id}":{"get":{"summary":"Read the status of a consumer account application","description":"Where a consumer's account onboarding stands, and — once the provider has approved it — the account and the Pix funding source it produced. Requires the `kyc:read` scope.\n\nTHIS IS NOT A PURE READ, and a caller should plan around that. On the approved branch the handler resolves the account from the provider and then persists: it inserts (or re-resolves) the consumer's `pix-celcoin` funding source, registers a Pix key for it, and anchors the consumer record. Every approval path in the product converges on that one place, so polling this endpoint is how an application becomes a usable funding source. It is idempotent — the funding source id is derived from the consumer id, and re-provisioning merges into the existing row rather than replacing it, so a Pix key already registered is not erased.\n\n`document_number` IS REQUIRED, and the reason is ownership. It is never used to address the provider: the handler loads the proposal recorded under this id and compares. A mismatch, or an id with no recorded proposal, is refused before any provider call and provisions nothing — the attack it closes is an attacker's own approved proposal presented with a victim's document.\n\nTHOSE OWNERSHIP REFUSALS ARRIVE AS 502, not 403. The handler's catch is blanket, so a typed ownership refusal is relabelled `kyc_status_failed` with the refusal's own sentence in `message`. Read the message, not just the code, before treating a 502 here as a provider outage.\n\nThe query is NOT schema-validated. The handler reads `request.query` directly and checks only that `document_number` is present; any other parameter is ignored rather than rejected.","parameters":[{"schema":{"type":"string","description":"The provider's proposal id, returned by `POST /v1/account-applications`."},"required":true,"description":"The provider's proposal id, returned by `POST /v1/account-applications`.","name":"id","in":"path"},{"schema":{"type":"string","description":"REQUIRED. The CPF or CNPJ the caller claims this application is about, checked against the document recorded for the proposal. Formatting is normalized before the comparison."},"required":true,"description":"REQUIRED. The CPF or CNPJ the caller claims this application is about, checked against the document recorded for the proposal. Formatting is normalized before the comparison.","name":"document_number","in":"query"},{"schema":{"type":"string","description":"A fallback only. The recorded proposal's own consumer id wins; this value is used when the recorded row carries none. Omitted, the proposal id stands in."},"required":false,"description":"A fallback only. The recorded proposal's own consumer id wins; this value is used when the recorded row carries none. Omitted, the proposal id stands in.","name":"consumer_id","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["pending","documentscopy_pending","approved","rejected"],"description":"FOUR values, and the exported `CelcoinOnboardingStatus` union has five. `unknown` is in the type and is not in this enum because `resolveCelcoinOnboarding` — the only producer this route has — never returns it: its four `return` statements carry `rejected`, `documentscopy_pending`, `pending` (twice) and `approved`. The only other `\"unknown\"` in that file is a `conflict_case`, which is a different field on a different shape.\n\n`pending` is also the answer when the proposal is past the known pending states but no account number could be parsed from the provider's fetch response. That case is logged with the raw body truncated, so a genuine not-minted-yet and a parse that is wrong look the same to the caller and different in the log."},"hosted_url":{"type":["string","null"],"description":"The documentoscopia webview the consumer opens, taken from the proposal's first `documentscopys` entry. Null on `rejected` and on `approved`."},"account":{"type":["string","null"],"description":"The provisioned account number once one exists, else null. Stringified: the provider returns it as either a string or a number.\n\nThe branch is resolved in the same fetch and is NOT returned by this operation; it is written into the funding source's `metadata.branch`."},"funding_source_id":{"type":["string","null"],"description":"The `pix-celcoin` funding source, or null before one exists. Deterministic — `fs_celcoin_` followed by the consumer id, one account per consumer — so a repeat call resolves the same row instead of opening a second one."},"pix_key":{"type":["string","null"],"description":"The DICT entry registered for the account, once registration has succeeded. Null before that."},"pix_key_type":{"type":["string","null"]},"pix_key_registration_error":{"type":"string","description":"ABSENT from the body unless Pix-key registration failed for an account that is otherwise real and usable. Deliberately non-blocking and deliberately not swallowed: the account works, the wallet's own lazy registration retries the step the next time the consumer needs to receive, and the failure is surfaced here so a caller can log or alert on it. Its presence does not make the application unapproved."}},"required":["status","hosted_url","account","funding_source_id","pix_key","pix_key_type"]}}}},"400":{"description":"Bad Request — `document_number` was not supplied. A BARE error body, not the `{ error: { code, message } }` envelope: this handler writes its failures inline and there is no `request_id` in the body.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["document_number_required"]}},"required":["error"]}}}},"502":{"description":"Bad Gateway. The provider call failed — and ALSO where the typed ownership refusals land, because the handler's catch is blanket. `message` carries the underlying sentence and is the only thing that separates a provider outage from a refusal to bind a document the proposal did not verify. Bare body, no `request_id`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["kyc_status_failed"]},"message":{"type":"string"}},"required":["error","message"]}}}}}}},"/v1/facilitator/x402/executions":{"post":{"summary":"Ingest and execute a foreign x402 authorization","description":"Takes an EIP-3009 `transferWithAuthorization` signed OUTSIDE CodeSpar by a foreign wallet, verifies it, translates it onto an internal mandate, runs it through the same cap gate every other spend passes, settles it on-chain and seals a receipt. A 200 here means USDC MOVED. Requires the `facilitator:execute` scope.\n\nOMITTING `intent.total_cap_minor` DOES NOT REMOVE THE LIFETIME CAP — it sets that cap equal to `max_amount_minor`. The translation reads `totalCap = totalCapAtomic ?? perTxCap`, and this route never marks the intent pay-per-access, so the gate compares the signer's PRIOR EXECUTED spend in this organization plus this payment against that cap. The practical consequence: leave `total_cap_minor` out and the SECOND execution by the same signer is refused with 422 `total_cap_exceeded`, even though the first succeeded and each authorization was independently signed. Send `total_cap_minor` explicitly whenever a signer may pay more than once.\n\nAMOUNTS ARE MINOR ON THE WIRE AND ATOMIC INSIDE. `max_amount_minor` and `total_cap_minor` are cents; they are converted to atomic USDC at the boundary, and the cap gate then compares them against `authorization.value`, which is already atomic. So the authorization's value is not scaled to match the caps — the caps are scaled to match it.\n\nThe evidence row is inserted `verified` only AFTER the payee binding, the replay guard and the amount guard have all passed — not when the signature verifies. A refusal at any of those three leaves NO row, so an authorization that verified cryptographically may have no record here at all. From that point every refusal marks the row `rejected` with its code in `reject_code`, and success marks it `executed` in the same transaction that seals the receipt, so the two commit together.\n\n`error.details` IS ALWAYS PRESENT on a failure, and is `{}` on most of them. It carries `foreign_authorization_id` in exactly two situations: when a row was created and then marked `rejected` (the cap gate, the mainnet ceiling, a settlement failure), and on the 409 replay — where the id is the PRIOR EXECUTED row's, not a new one. The 400 raised by body validation carries `issues` instead.\n\n`test` credentials always settle on `base-sepolia`; no flag can send them to mainnet. A `live` credential settles on `base` only when the operator has enabled mainnet, and is refused with 501 otherwise, before any wallet is touched.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"authorization":{"type":"object","properties":{"from":{"type":"string","pattern":"^0x[0-9a-fA-F]{40}$"},"to":{"type":"string","pattern":"^0x[0-9a-fA-F]{40}$"},"value":{"type":"string","pattern":"^\\d+$"},"validAfter":{"type":"string","pattern":"^\\d+$"},"validBefore":{"type":"string","pattern":"^\\d+$"},"nonce":{"type":"string","pattern":"^0x[0-9a-fA-F]{64}$"}},"required":["from","to","value","validAfter","validBefore","nonce"]},"domain":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":100},"version":{"type":"string","minLength":1,"maxLength":20},"chainId":{"type":"integer","exclusiveMinimum":0},"verifyingContract":{"type":"string","pattern":"^0x[0-9a-fA-F]{40}$"}},"required":["name","version","chainId","verifyingContract"]},"signature":{"type":"string","pattern":"^0x[0-9a-fA-F]{130}$"},"intent":{"type":"object","properties":{"payee":{"type":"string","pattern":"^0x[0-9a-fA-F]{40}$"},"resource":{"type":"string","minLength":1,"maxLength":300},"seller":{"type":"string","minLength":1,"maxLength":200},"purpose":{"type":"string","minLength":1,"maxLength":120},"max_amount_minor":{"type":"integer","exclusiveMinimum":0},"total_cap_minor":{"type":"integer","exclusiveMinimum":0},"resource_url":{"type":"string","maxLength":2048,"format":"uri"}},"required":["payee","resource","purpose","max_amount_minor"]}},"required":["authorization","domain","signature","intent"]}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["executed"]},"foreign_authorization_id":{"type":"string","description":"`fauth_`-prefixed. The id `GET /v1/facilitator/x402/executions/{id}` takes."},"mandate_id":{"type":"string"},"execution_id":{"type":"string"},"signer":{"type":"string","description":"The address recovered from the signature."},"settlement":{"type":"object","properties":{"tx_hash":{"type":"string"},"network":{"type":"string","description":"`base-sepolia` or `base`."},"money_moved":{"type":"boolean"},"adapter":{"type":"string","description":"`x402-facilitator-` followed by the network."}},"required":["tx_hash","network","money_moved","adapter"]},"receipt":{"type":"object","properties":{"id":{"type":"string"},"state":{"type":"string","enum":["paid","exception","delivered"],"description":"`delivered` once the rail-native delivery proof folds in — on a crypto rail the settlement transaction IS the proof, so there is no invoice to wait for. The fold is best-effort and never unwinds a settled transfer, so a `paid` here can mean the fold failed, not that the money did."},"chain":{"type":"string"}},"required":["id","state","chain"]}},"required":["status","foreign_authorization_id","mandate_id","execution_id","signer","settlement","receipt"]}}}},"400":{"description":"Bad Request. `missing_required_field` is the body failing validation, with the Zod issues in `error.details.issues`. `malformed_authorization` is the verifier or the payee binding rejecting a field the schema let through — the schema's address regex accepts any 40 hex digits, including a mixed-case address whose EIP-55 checksum is wrong, and the on-chain address parser does not. It covers `authorization.from`, `authorization.to`, `intent.payee` and `domain.verifyingContract`, and an out-of-range `domain.chainId`. `invalid_payload` is the cap gate refusing the translated mandate's shape.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["missing_required_field","malformed_authorization","invalid_payload"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"401":{"description":"Unauthorized — the SIGNATURE, not the credential. `bad_signature` is a signature that does not recover; `signer_mismatch` is one that recovers to an address other than `authorization.from`. A rejected API key or access token never reaches this handler.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["bad_signature","signer_mismatch"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"Conflict — this `(signer, nonce)` has already been executed, so it settles once. `error.details.foreign_authorization_id` names the row that ALREADY executed; no new row was created. The same code answers the race, where a concurrent execution won and the unique index on executed nonces refused the loser — in that case the id is the loser's own row.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["authorization_replayed"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"422":{"description":"Unprocessable — the request is well-formed and the signature is valid, but a bound refuses it. `authorization_not_yet_valid` and `authorization_expired` are the authorization's own window. `payee_authorization_mismatch` is `intent.payee` differing from the recipient the signature authorizes on-chain, compared after both are checksummed. `per_tx_cap_exceeded` and `total_cap_exceeded` are the cap gate; see the note above on what omitting `total_cap_minor` does. `expired` is the translated mandate outliving its own window between the two checks. `x402_mainnet_cap_exceeded` is the operator's per-payment mainnet ceiling, a second gate on top of the mandate's own.\n\nThe pipeline also guards against a non-positive amount with `amount_below_minimum`, which is NOT in this list: `authorization.value` is validated as strictly greater than zero before the handler runs, so that guard is defence in depth for other callers of the pipeline and is not a result an HTTP caller can produce.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["authorization_not_yet_valid","authorization_expired","payee_authorization_mismatch","per_tx_cap_exceeded","total_cap_exceeded","expired","x402_mainnet_cap_exceeded"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"500":{"description":"Internal error from the cap gate, surfaced rather than swallowed. Reached when the gate fails for a reason that should be impossible on this path — the mandate secret vanishing between the moment it was resolved and the moment the gate looked it up, say. `error.code` is left open here because it echoes whatever the gate reported.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"501":{"description":"Not Implemented — a `live` credential asked for mainnet settlement and the operator has not enabled it. Refused before any signature work, any database write or any wallet call, so nothing is left dangling. A `test` credential cannot reach this: it always settles on testnet.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["x402_mainnet_not_enabled"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"502":{"description":"Bad Gateway — on-chain settlement failed. The evidence row is marked `rejected` with `reject_code = 'settlement_failed'` and its id is in `error.details.foreign_authorization_id`. A broadcast is not a settlement: a transaction that reverts or is not confirmed in time lands here too, so a 502 does NOT mean the money is in flight.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["settlement_failed"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/facilitator/x402/executions/{id}":{"get":{"summary":"Read one ingested foreign authorization","description":"The evidence row for a foreign authorization: the signed object's own facts, and the internal objects it produced. Requires the `facilitator:execute` scope — the same scope as the POST, so a credential that can read this lane can also execute on it.\n\nThe evidence row is inserted `verified` only AFTER the payee binding, the replay guard and the amount guard have all passed — not when the signature verifies. A refusal at any of those three leaves NO row, so an authorization that verified cryptographically may have no record here at all. From that point every refusal marks the row `rejected` with its code in `reject_code`, and success marks it `executed` in the same transaction that seals the receipt, so the two commit together.\n\nThis row is NOT the money's source of truth — the settlement transaction and the ledger are — and it is NOT the mandate. It is the bridge that lets a receipt point back to the foreign trust root that authorized the spend.\n\nRESOLUTION IS SCOPED TO THE ORGANIZATION ONLY. The query predicates on `org_id` and the id, with no project term, so a row created under a sibling project in the same organization IS readable here — unlike the funding-source read in this same group, which is narrower.\n\nThe signed payload itself is stored on the row and is not returned: the authorization, the domain, the intent and the signature stay server-side.","parameters":[{"schema":{"type":"string","description":"`fauth_`-prefixed, as returned by the POST."},"required":true,"description":"`fauth_`-prefixed, as returned by the POST.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`fauth_`-prefixed."},"kind":{"type":"string","enum":["x402","ap2","acp"],"description":"Closed by the `kind` CHECK in `packages/api/src/migrations/0099_foreign_authorizations.sql`, which is also where this table's `status` set is closed. Only `x402` is written today; `ap2` and `acp` are reserved for the ingestion paths that reuse the same machinery."},"signer":{"type":"string","description":"The address RECOVERED from the signature, not the `from` the caller sent."},"payee":{"type":"string","description":"The on-chain recipient, checksummed."},"nonce":{"type":"string","description":"The EIP-3009 nonce, 32 bytes hex. Unique per authorization."},"chain_id":{"type":["integer","null"]},"asset":{"type":["string","null"],"description":"The token contract — the EIP-712 `verifyingContract`, checksummed."},"value_atomic":{"type":"string","description":"The exact authorized amount in atomic units (USDC, 6dp). This is what settles."},"amount_minor":{"type":"integer","description":"A COARSE cents figure, rounded UP from `value_atomic`, kept so older minor-denominated reads still work. It is NOT what the cap gate ran on and NOT what moved: the gate and the settlement both use atomic. Read `value_atomic` when the number has to be exact."},"status":{"type":"string","enum":["verified","executed","rejected"],"description":"Closed by `CHECK (status IN ('verified','executed','rejected'))` in `packages/api/src/migrations/0099_foreign_authorizations.sql`. The evidence row is inserted `verified` only AFTER the payee binding, the replay guard and the amount guard have all passed — not when the signature verifies. A refusal at any of those three leaves NO row, so an authorization that verified cryptographically may have no record here at all. From that point every refusal marks the row `rejected` with its code in `reject_code`, and success marks it `executed` in the same transaction that seals the receipt, so the two commit together."},"reject_code":{"type":["string","null"],"description":"The refusal ground on a `rejected` row: the cap-gate or mainnet-cap code, or the literal `settlement_failed`. It is the same string the POST answered with under `error.code`. Null otherwise, and an open string here because its source set is the cap gate's, not this table's."},"mandate_id":{"type":["string","null"],"description":"The internal mandate the foreign authorization was translated onto. Set on success."},"execution_id":{"type":["string","null"],"description":"`x402exec_`-prefixed. Set on success."},"receipt_id":{"type":["string","null"],"description":"The sealed Control Record receipt. Set on success."},"settlement_tx":{"type":["string","null"],"description":"The on-chain settlement transaction hash. Set on success."},"network":{"type":["string","null"],"description":"`base-sepolia` for a `test` credential, `base` for a `live` one. Stamped at insert time, so a `rejected` row carries it too."},"created_at":{"type":"string","format":"date-time"}},"required":["id","kind","signer","payee","nonce","chain_id","asset","value_atomic","amount_minor","status","reject_code","mandate_id","execution_id","receipt_id","settlement_tx","network","created_at"]}}}},"404":{"description":"Not Found. No foreign authorization with this id in the caller's organization — including the case where the authorization verified but was refused before any row was written.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["foreign_authorization_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/issuer/cards/{id}":{"get":{"deprecated":true,"summary":"Read one issued card, or the mint flow behind it","externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979 — canonical path and alias window"},"description":"DEPRECATED alias of `GET /v1/cards/{id}` (ent#979), kept for two releases. Same handler, same required scope; switch the path and nothing else changes.\n\nONE path, TWO bodies, chosen by the id's prefix. An `imf_` id is a mint flow and answers with the flow; anything else is read as a card row and answers with the card. Branch on the prefix you sent, or on `flow_id` being present, never on field order.\n\nResolution is scoped to the organization AND the project the credential authenticates for: both lookups filter on all three of id, org and project, so a card held by a sibling project in the same organization answers 404 here, indistinguishable from one that never existed.\n\nNo cardholder data crosses this boundary. The number is represented by `last4` and by the token id; PAN, CVV and expiry never enter the process that serves this route.\n\nRequires the `cards:read` scope.","parameters":[{"schema":{"type":"string","description":"`icrd_` for a card, `imf_` for a mint flow. The prefix selects the body."},"required":true,"description":"`icrd_` for a card, `imf_` for a mint flow. The prefix selects the body.","name":"id","in":"path"}],"responses":{"200":{"description":"OK. The card, or the mint flow when the id was an `imf_` one — see the description for which id yields which.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"id":{"type":"string","description":"`icrd_`-prefixed. Our row id for the card, not the issuer's handle."},"issuer":{"type":"string","enum":["pomelo","bridge"],"description":"The issuing rail the card was minted on. Closed in the type, not just in data."},"card_id":{"type":"string","description":"The issuer's own global handle for the card."},"last4":{"type":"string","description":"Last four digits. The only fragment of the number this API returns."},"network":{"type":"string","enum":["visa","mastercard"]},"status":{"type":"string","enum":["active","blocked","canceled"]},"token_id":{"type":["string","null"],"description":"The card-data-environment token id, null until the mint flow allocates one. Allocation happens BEFORE the card material is ingested, so a token id with no material behind it is a recorded failure rather than a silent gap. It is the handle a PCI-scoped surface exchanges for card material; no card material is reachable through this API with it."},"created_at":{"type":"string","format":"date-time"}},"required":["id","issuer","card_id","last4","network","status","token_id","created_at"]},{"type":"object","properties":{"flow_id":{"type":"string","description":"`imf_`-prefixed. The mint attempt, keyed by the caller's idempotency key."},"state":{"type":"string","enum":["pending","issued","revoked","failed"]},"issuer":{"type":"string","enum":["pomelo","bridge"]},"reason":{"type":["string","null"],"description":"Why the flow is parked or failed, when the lane recorded one."},"kyc_url":{"type":["string","null"],"description":"The issuer-hosted step the holder must complete before the flow can move on."},"created_at":{"type":"string","format":"date-time"},"card":{"type":["object","null"],"properties":{"id":{"type":"string","description":"`icrd_`-prefixed. Our row id for the card, not the issuer's handle."},"issuer":{"type":"string","enum":["pomelo","bridge"],"description":"The issuing rail the card was minted on. Closed in the type, not just in data."},"card_id":{"type":"string","description":"The issuer's own global handle for the card."},"last4":{"type":"string","description":"Last four digits. The only fragment of the number this API returns."},"network":{"type":"string","enum":["visa","mastercard"]},"status":{"type":"string","enum":["active","blocked","canceled"]},"token_id":{"type":["string","null"],"description":"The card-data-environment token id, null until the mint flow allocates one. Allocation happens BEFORE the card material is ingested, so a token id with no material behind it is a recorded failure rather than a silent gap. It is the handle a PCI-scoped surface exchanges for card material; no card material is reachable through this API with it."},"created_at":{"type":"string","format":"date-time"}},"required":["id","issuer","card_id","last4","network","status","token_id","created_at"],"description":"Present ONLY when the flow reached `issued` AND carries a card row id. It is then the card itself, or null if that row could not be read back. On every other state the key is absent rather than null, so `\"card\" in body` and `body.card !== null` are different questions."}},"required":["flow_id","state","issuer","reason","kyc_url","created_at"]}]}}}},"404":{"description":"Not Found. No card and no mint flow with this id in the caller's organization and project. Both branches answer with the same code and differ only in the message.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["issuer_card_unknown"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/kyc/onboard/{proposalId}/status":{"get":{"deprecated":true,"summary":"Poll a KYC account application","externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979 — canonical path and alias window"},"description":"DEPRECATED alias of `GET /v1/account-applications/{id}` (ent#979), kept for two releases. Same handler, same required scope; switch the path and nothing else changes. The canonical path spells this same value `{id}`.\n\nPoll the KYC application and, once the provider approves it, the account and funding source that were provisioned from it. This read is not read-only underneath: approval is what provisions the funding source and registers the Pix key, so the first poll that finds an approved application is the call that creates them. Provisioning is idempotent, so polling again does not create a second one.\n\nPossession is proved by the recorded proposal, never by the query. An application with no recorded proposal is refused rather than trusted, and a `document_number` that disagrees with the recorded document is refused before any provider call — an approved application of one's own plus somebody else's document number buys nothing.\n\nRequires the `kyc:read` scope.","parameters":[{"schema":{"type":"string","description":"The application id the create call returned. It addresses a recorded proposal, and an id with no recorded proposal is refused rather than looked up."},"required":true,"description":"The application id the create call returned. It addresses a recorded proposal, and an id with no recorded proposal is refused rather than looked up.","name":"proposalId","in":"path"},{"schema":{"type":"string","description":"REQUIRED, and its absence is a 400 rather than a default. It is a CLAIM about what the application is for, checked against the document the recorded proposal actually verified, and it never addresses the provider: the account is always fetched for the proposal's own document. A claim that does not match the recorded one is refused before any provider call and nothing is written."},"required":true,"description":"REQUIRED, and its absence is a 400 rather than a default. It is a CLAIM about what the application is for, checked against the document the recorded proposal actually verified, and it never addresses the provider: the account is always fetched for the proposal's own document. A claim that does not match the recorded one is refused before any provider call and nothing is written.","name":"document_number","in":"query"},{"schema":{"type":"string","description":"The consumer to bind a provisioned account to, when the recorded proposal does not already name one. Precedence runs the other way from what the parameter suggests: the recorded proposal's consumer WINS, and this value is the fallback used only when the proposal recorded none. Omitting it falls back to the id in the path."},"required":false,"description":"The consumer to bind a provisioned account to, when the recorded proposal does not already name one. Precedence runs the other way from what the parameter suggests: the recorded proposal's consumer WINS, and this value is the fallback used only when the proposal recorded none. Omitting it falls back to the id in the path.","name":"consumer_id","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["pending","documentscopy_pending","approved","rejected"],"description":"Four values, and the fourth is not the type's fifth. The resolver that serves this route is the only producer, and enumerating every one of its returns gives exactly these four; `unknown` exists in the shared union and is returned by nothing on this path.\n\n`documentscopy_pending` means the holder still has to finish the document check, and `hosted_url` is where they finish it. `pending` is the widest of the four: it covers the provider's own PENDING and PROCESSING, the case where it reported no status at all, and a proposal that is past those states but whose account could not yet be read back. Poll again for all three."},"hosted_url":{"type":["string","null"],"description":"The document-check webview link while it is outstanding; null otherwise."},"account":{"type":["string","null"],"description":"The provisioned account number, once one exists."},"funding_source_id":{"type":["string","null"],"description":"The consumer's funding source, written when the application is approved. Provisioning is idempotent on a key derived from the consumer, so polling this endpoint repeatedly after approval returns the same id and creates nothing new."},"pix_key":{"type":["string","null"],"description":"The Pix key registered for the account, either just now or previously — registration returns early when the key already exists, so a repeated poll does not re-register."},"pix_key_type":{"type":["string","null"]},"pix_key_registration_error":{"type":"string","description":"Present ONLY when the account was provisioned but its Pix-key registration failed. It is deliberately not an error status: the account is real and usable, and the key is retried lazily the next time the consumer needs to receive. Absent means either the key registered or there is no account yet — read `pix_key` to tell those apart."}},"required":["status","hosted_url","account","funding_source_id","pix_key","pix_key_type"]}}}},"400":{"description":"Bad Request — `document_number` was absent. A bare body with one key, not the `{ error: { code, message } }` envelope the newer routes use.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["document_number_required"]}},"required":["error"]}}}},"403":{"description":"Forbidden — the provisioning write named a resource outside the organization and project these credentials authenticate for. The body is the tenant control's own two keys and carries nothing from the database's message.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["write_scope_refused"]},"remediation":{"type":"string","description":"What to change. Fixed text per code; never derived from the request."}},"required":["error","remediation"]}}}},"502":{"description":"Bad Gateway — the provider lookup or the provisioning that follows it failed. Also the status for a refused ownership proof (no recorded proposal, or a `document_number` that does not match the one the proposal verified): `message` distinguishes them, the code does not.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["kyc_status_failed"]},"message":{"type":"string"}},"required":["error","message"]}}}}}}},"/v1/providers/{slug}/auth-schema":{"get":{"summary":"The connect form for one provider","description":"The form to render so an operator can connect this provider, and never a credential. The vault is write-only from this direction: no submitted value is readable back through any operation in this document.\n\n`fields` is empty for `oauth` (the connect flow is a redirect, not a form), for `none` (there is nothing to hold) and for any provider whose auth mechanism carries no per-field input. An empty array therefore means \"no form\", not \"not configured\".\n\n`base_url` and `oauth_authorize_url` are resolved for the environment the credential authenticates in, so what is previewed here is the host the call will actually reach.\n\nRequires the `servers:read` scope.","parameters":[{"schema":{"type":"string","description":"The provider slug, which is also the catalog primary key."},"required":true,"description":"The provider slug, which is also the catalog primary key.","name":"slug","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"server_id":{"type":"string","description":"Echo of the slug. The catalog primary key IS the slug — there is no second id."},"auth_type":{"type":"string","enum":["api_key","path_secret","oauth","cert","none","hmac_signed","jwt_ecdsa","two_header","cdp"],"description":"The provider's declared auth mechanism, taken straight from the column. Nine values, which is the column constraint's set and NOT the six the shared types package publishes: three of these nine are wire values the published schema would reject, so validate against this list rather than against a generated type of that field."},"environment":{"type":"string","enum":["live","test"],"description":"The environment the credential authenticates in. Not a parameter of the request."},"base_url":{"type":"string","description":"The provider host for this environment, or an empty string when the catalog holds no endpoint row for it."},"oauth_authorize_url":{"type":["string","null"],"description":"Where an OAuth connect lands the operator. Null for every other mechanism."},"fields":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","description":"The key to submit this value under when creating the connection. For a companion header it is the catalog ref suffix, not the header name."},"kind":{"type":"string","enum":["api_key","path_secret","header","cert"],"description":"What input to render. Four values, and the set is closed twice over: by enumerating every field the handler emits, and by the shared package's own enum, which agree.\n\nIt is NOT a mirror of the provider's `auth_type`. An `hmac_signed` provider emits `path_secret` fields because the form is identical; a `two_header` provider emits `header` fields. Branch the connect form on `auth_type`, and the individual input on `kind`."},"label":{"type":"string","description":"The display string. Treat it as authoritative rather than re-deriving one."},"header_name":{"type":"string","description":"Present on `header` fields: the HTTP header the value is stamped under."}},"required":["name","kind","label"]},"description":"In the order the form should render them."}},"required":["server_id","auth_type","environment","base_url","oauth_authorize_url","fields"]}}}},"404":{"description":"Not Found — no such provider for this caller. A generated provider belonging to another project answers here too, identically to one that does not exist: the listing already hides it, so answering differently would leak what the listing conceals.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["server_unknown"]},"server_id":{"type":"string","description":"Echo of the slug that was not resolvable."}},"required":["error","server_id"]}}}}}}},"/v1/providers/{slug}/verify-connection":{"post":{"summary":"Test one provider connection's credential","description":"Issue one known-safe read against the provider with the connection's own stored credential, so an operator learns that a credential does not authenticate here instead of learning it on an agent's first real call.\n\nThe check is a read on both of its lanes. A provider with a bespoke recipe gets that recipe's GET; every other provider falls back to re-running the read its own catalog entry declares as its probe, which is the same authenticated read that confirms a freshly provisioned sandbox. Which lane ran is visible in the body: only a recipe extracts an `account`.\n\nREAD THE OUTCOME FROM `ok`, NOT FROM THE STATUS. Every failure body carries `ok: false` and a stable `error`; the status is a mapping from that code and is the less precise of the two. One status in particular carries two different bodies — see the 404.\n\nEXACTLY ONE of the seven failures is a verdict about the credential: `provider_rejected`. The other six are the absence of a verdict, and they are not interchangeable with a bad credential. `redirect_not_followed` is the subtle one: the configured host answered by pointing somewhere else, the probe deliberately does not follow it, and so no answer about the credential was obtained from anyone.\n\nRequires the `connections:write` scope. It is the scope the connection family carries, and it is wider than this operation needs: nothing in this project is written by asking a provider to judge a credential.","parameters":[{"schema":{"type":"string","description":"The provider slug, which is also the catalog primary key."},"required":true,"description":"The provider slug, which is also the catalog primary key.","name":"slug","in":"path"}],"responses":{"200":{"description":"OK. The credential authenticated at the provider. `latency_ms` is the probe's own round trip.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]},"provider":{"type":"string"},"account":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"extras":{"type":"object","additionalProperties":{},"description":"Whatever the provider's own reply carried that is worth showing an operator."}},"description":"Absent when the check ran through the generic probe rather than a provider-specific recipe: only a recipe extracts an account summary. Its absence is not a weaker pass."},"latency_ms":{"type":"integer"}},"required":["ok","provider","latency_ms"]}}}},"401":{"description":"The PROVIDER rejected the stored credential — a verdict, and the only failure here that is one. `detail` carries the provider's own words. This 401 never describes the caller's own credential: one that does not authenticate is refused before the handler runs.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[false]},"provider":{"type":"string"},"error":{"type":"string","enum":["server_unknown","verify_unsupported","not_connected","provider_rejected","provider_unreachable","redirect_not_followed","endpoint_missing"]},"detail":{"type":"string","description":"Whose sentence this is depends on the code, and the difference matters. On `provider_rejected` and `provider_unreachable` it is up to 256 characters of the PROVIDER's own response body, or the transport error. On the others it is text we wrote, naming what is missing on our side. It is absent on `redirect_not_followed`, and on the `server_unknown` and `endpoint_missing` refusals that carry nothing but the code."},"status":{"type":"integer","description":"The provider's HTTP status, present only when a round trip completed: on `provider_rejected`, `redirect_not_followed`, and the `provider_unreachable` that came from a response rather than from a transport failure. Its ABSENCE on a 502 is the signal that nothing was reached at all."},"latency_ms":{"type":"integer"},"authType":{"type":"string","description":"camelCase, unlike every other key here, because the outcome is spread onto the body verbatim. Present on `not_connected` when the resolver knew the provider's mechanism; it is what separates a connect an operator can self-serve from a rail that needs operator-issued material."},"hint":{"type":"string","description":"Added on `not_connected` only: the sandbox path that works without a connection."},"alternative":{"type":"string","description":"Added on `not_connected` only: the meta-tool to call instead."},"caveat":{"type":"string","description":"Added only for a `cert` rail in the test environment, where the connect cannot be completed at all rather than merely being unfinished."}},"required":["ok","provider","error"]}}}},"404":{"description":"Not Found, and this status carries TWO different bodies. A generated provider belonging to another project is refused before the check runs, with the bare `{ error, server_id }` shape and no `ok` key. A provider that has a verify recipe but no catalog row reaches the checker and comes back as an ordinary failure with `ok: false` and `error: \"server_unknown\"`. Branch on `ok`, not on the status.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"string","enum":["server_unknown"]},"server_id":{"type":"string","description":"Echo of the slug that was not resolvable."}},"required":["error","server_id"]},{"type":"object","properties":{"ok":{"type":"boolean","enum":[false]},"provider":{"type":"string"},"error":{"type":"string","enum":["server_unknown","verify_unsupported","not_connected","provider_rejected","provider_unreachable","redirect_not_followed","endpoint_missing"]},"detail":{"type":"string","description":"Whose sentence this is depends on the code, and the difference matters. On `provider_rejected` and `provider_unreachable` it is up to 256 characters of the PROVIDER's own response body, or the transport error. On the others it is text we wrote, naming what is missing on our side. It is absent on `redirect_not_followed`, and on the `server_unknown` and `endpoint_missing` refusals that carry nothing but the code."},"status":{"type":"integer","description":"The provider's HTTP status, present only when a round trip completed: on `provider_rejected`, `redirect_not_followed`, and the `provider_unreachable` that came from a response rather than from a transport failure. Its ABSENCE on a 502 is the signal that nothing was reached at all."},"latency_ms":{"type":"integer"},"authType":{"type":"string","description":"camelCase, unlike every other key here, because the outcome is spread onto the body verbatim. Present on `not_connected` when the resolver knew the provider's mechanism; it is what separates a connect an operator can self-serve from a rail that needs operator-issued material."},"hint":{"type":"string","description":"Added on `not_connected` only: the sandbox path that works without a connection."},"alternative":{"type":"string","description":"Added on `not_connected` only: the meta-tool to call instead."},"caveat":{"type":"string","description":"Added only for a `cert` rail in the test environment, where the connect cannot be completed at all rather than merely being unfinished."}},"required":["ok","provider","error"]}]}}}},"409":{"description":"`redirect_not_followed`. The configured host answered with a redirect, which the probe does not follow, so the credential was never judged. Retrying cannot resolve it and reconfiguring can — which is why it is not a 502.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[false]},"provider":{"type":"string"},"error":{"type":"string","enum":["server_unknown","verify_unsupported","not_connected","provider_rejected","provider_unreachable","redirect_not_followed","endpoint_missing"]},"detail":{"type":"string","description":"Whose sentence this is depends on the code, and the difference matters. On `provider_rejected` and `provider_unreachable` it is up to 256 characters of the PROVIDER's own response body, or the transport error. On the others it is text we wrote, naming what is missing on our side. It is absent on `redirect_not_followed`, and on the `server_unknown` and `endpoint_missing` refusals that carry nothing but the code."},"status":{"type":"integer","description":"The provider's HTTP status, present only when a round trip completed: on `provider_rejected`, `redirect_not_followed`, and the `provider_unreachable` that came from a response rather than from a transport failure. Its ABSENCE on a 502 is the signal that nothing was reached at all."},"latency_ms":{"type":"integer"},"authType":{"type":"string","description":"camelCase, unlike every other key here, because the outcome is spread onto the body verbatim. Present on `not_connected` when the resolver knew the provider's mechanism; it is what separates a connect an operator can self-serve from a rail that needs operator-issued material."},"hint":{"type":"string","description":"Added on `not_connected` only: the sandbox path that works without a connection."},"alternative":{"type":"string","description":"Added on `not_connected` only: the meta-tool to call instead."},"caveat":{"type":"string","description":"Added only for a `cert` rail in the test environment, where the connect cannot be completed at all rather than merely being unfinished."}},"required":["ok","provider","error"]}}}},"424":{"description":"`not_connected`. There is no usable credential to test: no active connection for this project and provider, or the vault returned nothing for the reference it holds. This body carries the extra `hint` / `alternative` steer.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[false]},"provider":{"type":"string"},"error":{"type":"string","enum":["server_unknown","verify_unsupported","not_connected","provider_rejected","provider_unreachable","redirect_not_followed","endpoint_missing"]},"detail":{"type":"string","description":"Whose sentence this is depends on the code, and the difference matters. On `provider_rejected` and `provider_unreachable` it is up to 256 characters of the PROVIDER's own response body, or the transport error. On the others it is text we wrote, naming what is missing on our side. It is absent on `redirect_not_followed`, and on the `server_unknown` and `endpoint_missing` refusals that carry nothing but the code."},"status":{"type":"integer","description":"The provider's HTTP status, present only when a round trip completed: on `provider_rejected`, `redirect_not_followed`, and the `provider_unreachable` that came from a response rather than from a transport failure. Its ABSENCE on a 502 is the signal that nothing was reached at all."},"latency_ms":{"type":"integer"},"authType":{"type":"string","description":"camelCase, unlike every other key here, because the outcome is spread onto the body verbatim. Present on `not_connected` when the resolver knew the provider's mechanism; it is what separates a connect an operator can self-serve from a rail that needs operator-issued material."},"hint":{"type":"string","description":"Added on `not_connected` only: the sandbox path that works without a connection."},"alternative":{"type":"string","description":"Added on `not_connected` only: the meta-tool to call instead."},"caveat":{"type":"string","description":"Added only for a `cert` rail in the test environment, where the connect cannot be completed at all rather than merely being unfinished."}},"required":["ok","provider","error"]}}}},"501":{"description":"`verify_unsupported`. Nothing ships that can check this provider — no recipe and no declared probe. The first real call will report authentication errors directly.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[false]},"provider":{"type":"string"},"error":{"type":"string","enum":["server_unknown","verify_unsupported","not_connected","provider_rejected","provider_unreachable","redirect_not_followed","endpoint_missing"]},"detail":{"type":"string","description":"Whose sentence this is depends on the code, and the difference matters. On `provider_rejected` and `provider_unreachable` it is up to 256 characters of the PROVIDER's own response body, or the transport error. On the others it is text we wrote, naming what is missing on our side. It is absent on `redirect_not_followed`, and on the `server_unknown` and `endpoint_missing` refusals that carry nothing but the code."},"status":{"type":"integer","description":"The provider's HTTP status, present only when a round trip completed: on `provider_rejected`, `redirect_not_followed`, and the `provider_unreachable` that came from a response rather than from a transport failure. Its ABSENCE on a 502 is the signal that nothing was reached at all."},"latency_ms":{"type":"integer"},"authType":{"type":"string","description":"camelCase, unlike every other key here, because the outcome is spread onto the body verbatim. Present on `not_connected` when the resolver knew the provider's mechanism; it is what separates a connect an operator can self-serve from a rail that needs operator-issued material."},"hint":{"type":"string","description":"Added on `not_connected` only: the sandbox path that works without a connection."},"alternative":{"type":"string","description":"Added on `not_connected` only: the meta-tool to call instead."},"caveat":{"type":"string","description":"Added only for a `cert` rail in the test environment, where the connect cannot be completed at all rather than merely being unfinished."}},"required":["ok","provider","error"]}}}},"502":{"description":"`provider_unreachable`, and it is WIDER than its name. It is returned for a transport failure or timeout, and also for EVERY provider response that is not 2xx, 3xx, 401 or 403 — a provider 400 or 404 arrives here too. Read `status` before retrying: absent means nothing was reached and a retry is reasonable; a 4xx there means the provider answered and will answer the same way again.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[false]},"provider":{"type":"string"},"error":{"type":"string","enum":["server_unknown","verify_unsupported","not_connected","provider_rejected","provider_unreachable","redirect_not_followed","endpoint_missing"]},"detail":{"type":"string","description":"Whose sentence this is depends on the code, and the difference matters. On `provider_rejected` and `provider_unreachable` it is up to 256 characters of the PROVIDER's own response body, or the transport error. On the others it is text we wrote, naming what is missing on our side. It is absent on `redirect_not_followed`, and on the `server_unknown` and `endpoint_missing` refusals that carry nothing but the code."},"status":{"type":"integer","description":"The provider's HTTP status, present only when a round trip completed: on `provider_rejected`, `redirect_not_followed`, and the `provider_unreachable` that came from a response rather than from a transport failure. Its ABSENCE on a 502 is the signal that nothing was reached at all."},"latency_ms":{"type":"integer"},"authType":{"type":"string","description":"camelCase, unlike every other key here, because the outcome is spread onto the body verbatim. Present on `not_connected` when the resolver knew the provider's mechanism; it is what separates a connect an operator can self-serve from a rail that needs operator-issued material."},"hint":{"type":"string","description":"Added on `not_connected` only: the sandbox path that works without a connection."},"alternative":{"type":"string","description":"Added on `not_connected` only: the meta-tool to call instead."},"caveat":{"type":"string","description":"Added only for a `cert` rail in the test environment, where the connect cannot be completed at all rather than merely being unfinished."}},"required":["ok","provider","error"]}}}},"503":{"description":"`endpoint_missing`. The provider is known but its catalog entry cannot be called as configured. An operator fixes this; a caller cannot.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[false]},"provider":{"type":"string"},"error":{"type":"string","enum":["server_unknown","verify_unsupported","not_connected","provider_rejected","provider_unreachable","redirect_not_followed","endpoint_missing"]},"detail":{"type":"string","description":"Whose sentence this is depends on the code, and the difference matters. On `provider_rejected` and `provider_unreachable` it is up to 256 characters of the PROVIDER's own response body, or the transport error. On the others it is text we wrote, naming what is missing on our side. It is absent on `redirect_not_followed`, and on the `server_unknown` and `endpoint_missing` refusals that carry nothing but the code."},"status":{"type":"integer","description":"The provider's HTTP status, present only when a round trip completed: on `provider_rejected`, `redirect_not_followed`, and the `provider_unreachable` that came from a response rather than from a transport failure. Its ABSENCE on a 502 is the signal that nothing was reached at all."},"latency_ms":{"type":"integer"},"authType":{"type":"string","description":"camelCase, unlike every other key here, because the outcome is spread onto the body verbatim. Present on `not_connected` when the resolver knew the provider's mechanism; it is what separates a connect an operator can self-serve from a rail that needs operator-issued material."},"hint":{"type":"string","description":"Added on `not_connected` only: the sandbox path that works without a connection."},"alternative":{"type":"string","description":"Added on `not_connected` only: the meta-tool to call instead."},"caveat":{"type":"string","description":"Added only for a `cert` rail in the test environment, where the connect cannot be completed at all rather than merely being unfinished."}},"required":["ok","provider","error"]}}}}}}},"/v1/discovery/manifest":{"get":{"summary":"The project's x402 discovery manifest","description":"The caller's own x402 manifest: the machine-readable index of what a crawler or an agent can pay for, in the same shape the public gateway serves it, so a dashboard can preview the real thing rather than a reconstruction.\n\nThe content is the project's ACTIVE paywalls in the environment the credential authenticates in, oldest first. Nothing here is a secret: the same manifest is served unauthenticated at the gateway, because every field in it is already exposed by the paywall URLs it indexes. A project with no active paywalls answers with an empty `resources` array, which is a well-formed manifest and not an error.\n\nThe prices mirror the live challenge, including the current tier for a tiered paywall — so what a crawler reads here is what it would be quoted by calling the URL.\n\nRequires the `servers:read` scope.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"manifest":{"type":"object","properties":{"x402Version":{"type":"number","enum":[2],"description":"camelCase and a fixed 2, both as the x402 wire format requires."},"resources":{"type":"array","items":{"type":"object","properties":{"url":{"type":"string","description":"The public gateway URL a payer calls, which is the gateway origin plus the slug."},"description":{"type":"string","description":"The paywall's name."},"pricing_model":{"type":"string","enum":["flat","token","dynamic","time","per_unit","tiered","metered"],"description":"The column's seven values. `flat` is also what a row with no model recorded reports."},"variable_price":{"type":"boolean","description":"Present, and true, ONLY when `amount` is indicative: a `dynamic` resource, whose real price comes from the seller's hook per request and whose `amount` is the fallback; or a `metered` resource, whose `amount` is the ceiling that is metered down and refunded.\n\nAbsent does not mean immutable. A `tiered` resource carries an exact price for its CURRENT tier and no flag, and that price moves when the tier does. It also stays absent on a `metered` resource whose metering is switched off for the deployment, which then advertises its flat base — so read it as \"this number is not exact\", never as \"this number will not change\"."},"accepts":{"type":"array","items":{"type":"object","properties":{"scheme":{"type":"string","enum":["exact"]},"network":{"type":"string","description":"CAIP-2. `eip155:84532` in the test environment and `eip155:8453` in live — Base Sepolia and Base."},"asset":{"type":"string","description":"The USDC contract address on that network."},"amount":{"type":"string","description":"Atomic units, as a decimal string because it does not fit a JSON number safely. What it MEANS depends on the resource: an exact price, a fallback, or a ceiling. `variable_price` on the resource is what tells them apart."},"payTo":{"type":"string","description":"camelCase, as the x402 wire format spells it."},"maxTimeoutSeconds":{"type":"integer","description":"120 for every resource; it is a literal, not a setting."},"extra":{"type":"object","properties":{"name":{"type":"string","description":"`USDC` in test, `USD Coin` in live — the token's own EIP-712 name."},"version":{"type":"string"}},"required":["name","version"]}},"required":["scheme","network","asset","amount","payTo","maxTimeoutSeconds","extra"]},"description":"One entry per resource today."}},"required":["url","description","pricing_model","accepts"]}}},"required":["x402Version","resources"]},"project_id":{"type":"string","description":"Echo of the project the credential resolved to."},"environment":{"type":"string","enum":["live","test"],"description":"Echo of the environment. It selects the paywalls AND the network and token in every `accepts` entry, so one manifest never mixes the two."}},"required":["manifest","project_id","environment"]}}}}}}},"/v1/health":{"get":{"summary":"Read graded dependency health, as this credential's tenant sees it","description":"Six dependency checks and the schema version, graded into one `status`. The checks run in parallel behind a three-second budget each; one that throws or times out is reported in its own failed shape with `message` carrying the reason, and the response still carries the other five. So the operation is expected back in under four seconds however badly the database is behaving.\n\nGRADE ON `status`, AND READ THE RULE BEFORE WRITING THE ALERT, because the rule is narrower than \"anything off nominal\". `down` means `db.ok` or `vault.ok` is false. `degraded` means one of exactly four things: `embeddings.status` is `empty`, `fx_rates.status` is anything other than `fresh`, `telemetry.status` is `stale`, or `connections.status` is `none`. Everything else grades `healthy`.\n\nTHREE NON-NOMINAL VALUES DO NOT MOVE THE GRADE, and an alert that wants them has to read the field rather than the grade: `embeddings.status` `low` (fewer than half the tool rows carry an embedding), `connections.status` `partial` (one or two connections rather than three or more), and `telemetry.status` `idle` (a quiet hour that still had traffic inside the day). All three come back `healthy` at the top level, on purpose — none of them stops a call from being served.\n\nONLY HALF OF ONE CHECK IS PER TENANT. `connections.connected` counts connections in the calling credential's own org and project. `connections.total_servers` counts every active provider in the catalog and is the same number for every reader, so the ratio between them is not a tenant completion percentage.\n\nTHIS READ WRITES, and the write is observable. Each call stores a snapshot of its own result against the calling org, and when the grade differs from the previous snapshot's it publishes `system.health.degraded` or `system.health.recovered` into the calling project, carrying a field-by-field diff of what moved. Anything subscribed to those event types in that project fires. Polling this endpoint therefore has side effects, and two monitors polling the same org write into one shared snapshot history — a transition either of them causes is seen once, by whoever's call crossed it.\n\nTHE HANDLER ANSWERS 200 FOR EVERY STATE IT GRADES, `down` included: a dead dependency is reported in the body and never mapped onto an HTTP status. That is why the map below has one entry. It is not a promise that nothing else can come back. Three things produce a non-200 here and none of them is a grade: a credential that does not authenticate, a credential whose grants do not cover this read — both refused before the handler runs, and neither described in the map below, because the answer to them is the same on every operation the same door guards — and a refusal of the snapshot write above, which is a defect on our side rather than a dependency being down. Alert on `status`. A non-200 is a bug report, and which of the three it is decides who should be reading it.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["healthy","degraded","down"],"description":"`down` iff the database or the vault check failed. `degraded` iff embeddings are empty, FX is not fresh, telemetry is stale, or the tenant has no connection. `healthy` otherwise — which includes embeddings `low`, connections `partial` and telemetry `idle`."},"checks":{"type":"object","properties":{"db":{"type":"object","properties":{"ok":{"type":"boolean"},"message":{"type":"string","description":"Present only when the check failed or timed out."},"latency_ms":{"type":"integer","description":"Round trip of a trivial query, measured from before the call, so a timeout reports the full budget."}},"required":["ok","latency_ms"]},"vault":{"type":"object","properties":{"ok":{"type":"boolean","description":"Storage liveness only. Nothing is decrypted, so this says the secret store is reachable, not that a given secret can be read."},"message":{"type":"string","description":"Present only when the check failed or timed out."}},"required":["ok"]},"embeddings":{"type":"object","properties":{"populated":{"type":"integer","description":"Tool rows carrying an embedding."},"total":{"type":"integer","description":"Tool rows in the catalog. Platform-wide, not per tenant."},"percent":{"type":"number","description":"`populated` over `total` as a percentage, rounded to one decimal. Zero when `total` is zero."},"status":{"type":"string","enum":["ok","low","empty"],"description":"`empty` iff `populated` is zero; `low` under 50 percent; `ok` otherwise. Only `empty` grades the response `degraded`."},"message":{"type":"string","description":"Present only when the check failed or timed out."}},"required":["populated","total","percent","status"]},"fx_rates":{"type":"object","properties":{"last_fetched_at":{"type":["string","null"],"format":"date-time","description":"When the USD/BRL rate was last fetched. Null when no rate has ever been stored, and null when the check itself failed."},"hours_old":{"type":["number","null"],"description":"Age of that fetch in hours, to one decimal."},"status":{"type":"string","enum":["fresh","stale","missing"],"description":"`stale` past 72 hours, which is the age at which the router stops normalizing across currencies. `missing` when there is no row, or when the check failed. Anything other than `fresh` grades the response `degraded`."},"message":{"type":"string","description":"Present only when the check failed or timed out."}},"required":["last_fetched_at","hours_old","status"]},"telemetry":{"type":"object","properties":{"last_hour_attempts":{"type":"integer"},"last_24h_attempts":{"type":"integer"},"status":{"type":"string","enum":["active","idle","stale"],"description":"`active` with traffic in the last hour, `idle` with none in the hour but some in the day, `stale` with none in the day. Only `stale` grades the response `degraded`: a quiet hour is normal."},"message":{"type":"string","description":"Present only when the check failed or timed out."}},"required":["last_hour_attempts","last_24h_attempts","status"]},"connections":{"type":"object","properties":{"connected":{"type":"integer","description":"Connections in the calling org AND project. This is the only per-tenant number in the whole body."},"total_servers":{"type":"integer","description":"Active providers in the catalog, platform-wide. Not filtered by tenant, so it is the same for every reader."},"status":{"type":"string","enum":["wired","partial","none"],"description":"`none` at zero connections, `partial` at one or two, `wired` at three or more. Only `none` grades the response `degraded`."},"message":{"type":"string","description":"Present only when the check failed or timed out."}},"required":["connected","total_servers","status"]}},"required":["db","vault","embeddings","fx_rates","telemetry","connections"]},"schema_version":{"type":"object","properties":{"latest":{"type":"string","description":"Name of the most recently applied migration. The literal `none` when the table holds no row, and the literal `unknown` when the lookup failed or timed out — neither is a migration name and neither moves `status`."},"applied_at":{"type":["string","null"],"format":"date-time","description":"Null in both of the cases above."}},"required":["latest","applied_at"]},"observed_at":{"type":"string","format":"date-time","description":"When this call built the response. It is also the snapshot key, so two calls landing in the same instant store one row."}},"required":["status","checks","schema_version","observed_at"]}}}}}}},"/v1/organizations/{id}":{"get":{"summary":"Read the organization this credential belongs to","description":"Completeness, not a management surface: every credential can learn who it belongs to. There is no list, no create and no update on this resource, and their absence is the contract — an organization is provisioned through sign-up and billing, not through this API.\n\nEXACTLY ONE ORGANIZATION IS READABLE, the caller's own. Any other value in `{id}` answers 404 rather than 403, for the reason the rest of this surface answers 404 across tenants: a 403 would confirm the organization exists, which is the one fact a caller probing ids is trying to buy. The id is in the path anyway, rather than the route being `/v1/organization`, so a caller that already knows its id gets a URL it can construct.\n\n`plan` and the billing customer handle are NOT on the wire. The first is a commercial fact shown under human identity and the second is a handle into another vendor's namespace; neither is something a credential needs in order to know who it belongs to.\n\nThe 500 is ours, not yours: it means the credential resolved to an organization that has no row, which is our state being torn. Retrying will not help.","parameters":[{"schema":{"type":"string","description":"The calling credential's own organization id — take it from `GET /v1/whoami` rather than constructing it. DO NOT ASSUME AN `org_` PREFIX: a developer working outside a shared organization is provisioned into a personal namespace whose id is `user_` followed by their user id, and that is a real organization row and a legal value here."},"required":true,"description":"The calling credential's own organization id — take it from `GET /v1/whoami` rather than constructing it. DO NOT ASSUME AN `org_` PREFIX: a developer working outside a shared organization is provisioned into a personal namespace whose id is `user_` followed by their user id, and that is a real organization row and a legal value here.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"Echoes the path parameter, which is always the caller's own organization."},"name":{"type":"string"},"created_at":{"type":"string","format":"date-time"}},"required":["id","name","created_at"]}}}},"404":{"description":"The id is not the calling credential's organization. Also what a caller gets for an organization that exists but is not theirs.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"500":{"description":"The authenticated organization has no row. A defect on our side, not a caller error.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["organization_missing"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/events/{event_id}/replay":{"post":{"summary":"Re-dispatch a stored event to its subscribers","description":"Re-runs the fan-out for an event that is already stored. The event itself is untouched: no new event row is written, so server-side de-duplication and the audit trail stay intact, and each dispatch appends its own fresh delivery row.\n\nThe event resolves only inside the calling credential's org AND project, so an event id from a sibling project is 404 here rather than replayed into the current project's subscribers.\n\nWITHOUT `trigger_id` the event goes to every ACTIVE subscription in this project whose subscribed event type matches the stored event's. With it, the fan-out is narrowed to that one subscription, which is the useful shape when debugging a single receiver — and the subscription is checked up front, so a mismatch or a paused subscription is refused before anything is delivered rather than dropped silently downstream.\n\n202, NOT 200, AND THE NUMBER IS A HANDOFF COUNT. `dispatched` is how many active subscriptions the event was handed to. Delivery is attempted for all of them and each settles independently, so a receiver that is down does not lower this number and does not fail the call. Zero means nothing in this project subscribes to this event's type — it is not an error, and it is the answer you get when the subscription you meant lives in another project.","parameters":[{"schema":{"type":"string","description":"Id of a stored event in the calling credential's org and project."},"required":true,"description":"Id of a stored event in the calling credential's org and project.","name":"event_id","in":"path"},{"schema":{"type":"string","description":"Narrow the fan-out to one subscription. It must live in the same org and project, subscribe to this event's type, and be active; each of those is a different refusal below."},"required":false,"description":"Narrow the fan-out to one subscription. It must live in the same org and project, subscribe to this event's type, and be active; each of those is a different refusal below.","name":"trigger_id","in":"query"}],"responses":{"202":{"description":"Accepted — the dispatches were handed off. It is not a statement that any receiver answered.","content":{"application/json":{"schema":{"type":"object","properties":{"event_id":{"type":"string","description":"Echoes the replayed event. Unchanged: replay does not mint a new event."},"dispatched":{"type":"integer","description":"Active subscriptions the event was handed to. Counted before delivery, so a failing receiver does not lower it."},"trigger_id":{"type":["string","null"],"description":"Echoes the query parameter, or null when the fan-out was not narrowed."}},"required":["event_id","dispatched","trigger_id"]}}}},"400":{"description":"The named subscription is subscribed to a different event type than the stored event. `details` carries `trigger_event` and `event_type` so the mismatch does not have to be guessed.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["trigger_event_mismatch"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"No such event, or no such subscription, inside the calling credential's org and project. The two codes are distinct so a caller can tell which of the two ids was wrong.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found","trigger_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"The named subscription exists and matches the event type but is not active. `details.status` carries the state it is actually in.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["trigger_not_active"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/meta-tools/discover":{"post":{"summary":"Ask which route to take for an intent, and why","description":"A planning surface, and never a required step: an agent that already knows which tool it wants calls that tool directly. Read-only in the contract sense — it dispatches no payment and writes no audit entry.\n\nTWO WAYS IN, ONE RANKING. Send `{ intent: { ... } }` and the lookup is deterministic and free. Send `{ description: \"...\" }` and the description is first turned into that same structured intent, which requires the managed tier; a deployment without the extractor answers 501 rather than guessing. Either way the resolved intent is echoed back in `resolved_intent`, which is how the natural-language caller sees what its sentence was read as.\n\n`recommended` IS A RANKING, NOT A RESERVATION. The router runs again at execution time and may pick a different provider if telemetry has moved, which is why `provider_id` is published for transparency and is not a value the agent passes back. `alternatives` holds the other eligible routes in score-descending order, with the recommended one removed — so it is empty when only one route was eligible, and that emptiness is not a failure.\n\n`args_template` is a starting point, not a complete call: it carries forward the fields the intent already supplied. The recipient and anything else the tool requires are the agent's to fill.\n\nONE REPAIR RUNS BEHIND THE 424, at most once every thirty seconds per project. When nothing is eligible, the most common cause is a per-tenant eligibility set that was never refreshed rather than a genuinely unconfigured project, so the endpoint re-seeds it idempotently and retries the lookup exactly once before answering. The retry can only surface what the refreshed catalog honestly allows. A second call inside the window skips the repair and answers from what is already there, so a client that polls on a 424 will not make it heal faster.\n\nTHE TWO 424 CODES MEAN DIFFERENT THINGS AND THE DIFFERENCE IS ACTIONABLE. `eligibility_empty` means nothing is configured for this combination and connecting a provider is the fix. `direction_blocked` means candidates exist but every one of them moves money the wrong way for this operation — connecting another provider does not fix it, because the rail itself does not pay out here.","requestBody":{"description":"Either a structured intent or a free-form description. One of the two shapes; a body carrying both fields is read as the first that matches.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"intent":{"type":"object","properties":{"operation":{"type":"string","enum":["charge","refund","payout","issue_invoice","send_notification","ship"]},"amount_minor":{"type":"integer","exclusiveMinimum":0},"currency":{"type":"string","minLength":2,"maxLength":8},"country":{"type":"string","minLength":2,"maxLength":2},"urgency":{"type":"string","enum":["instant","same_day","next_day","no_rush"]},"recipient_kind":{"type":"string","enum":["cnpj","cpf","email","phone","wallet","other"]},"metadata":{"type":"object","additionalProperties":{}}},"required":["operation","currency","country"]}},"required":["intent"]},{"type":"object","properties":{"description":{"type":"string","minLength":1,"maxLength":2000}},"required":["description"]}]}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"recommended":{"type":"object","properties":{"tool":{"type":"string","description":"The canonical tool the agent should call."},"rail":{"type":"string","description":"The money rail that tool resolves to for this intent."},"provider_id":{"type":"string","description":"Transparency only. Do not pass it back: the router re-runs at execution time and may pick differently."},"args_template":{"type":"object","additionalProperties":{},"description":"The fields the intent already supplied, pre-filled. The agent completes the rest."},"reasons":{"type":"array","items":{"type":"string"},"description":"Why this route scored where it did."}},"required":["tool","rail","provider_id","args_template","reasons"]},"alternatives":{"type":"array","items":{"type":"object","properties":{"tool":{"type":"string"},"rail":{"type":"string"},"provider_id":{"type":"string"},"cost_minor":{"type":"integer","description":"Modelled cost in minor units."},"latency_p50_ms":{"type":"integer","description":"Observed median latency, rounded to whole milliseconds."},"success_rate":{"type":"number","description":"Observed success rate for this provider on this rail."},"reasons":{"type":"array","items":{"type":"string"}}},"required":["tool","rail","provider_id","cost_minor","latency_p50_ms","success_rate","reasons"]},"description":"The other eligible routes, score-descending, with the recommended one removed. Empty when only one route was eligible."},"resolved_intent":{"type":"object","properties":{"operation":{"type":"string","enum":["charge","refund","payout","issue_invoice","send_notification","ship"]},"amount_minor":{"type":"integer","exclusiveMinimum":0},"currency":{"type":"string"},"country":{"type":"string","minLength":2,"maxLength":2},"urgency":{"type":"string","enum":["instant","same_day","next_day","no_rush"]},"recipient_kind":{"type":"string","enum":["cnpj","cpf","email","phone","wallet","other"]},"metadata":{"type":"object","additionalProperties":{}}},"required":["operation","currency","country"],"description":"The structured intent the lookup actually ran on. Echoed verbatim for the structured caller; for the natural-language caller it is how the description was read."}},"required":["recommended","alternatives","resolved_intent"]}}}},"400":{"description":"Either the body matched neither accepted shape, or it was a valid intent for an operation no tool ships yet. The two are different bodies: the first carries `issues`, the second carries `detail`.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body"]},"issues":{"type":"array","items":{},"description":"Validation issues, one per rejected field."}},"required":["error","issues"]},{"type":"object","properties":{"error":{"type":"string","enum":["intent_unmappable"]},"detail":{"type":"string"}},"required":["error"]}]}}}},"424":{"description":"The intent is understood and nothing can serve it. Read the code: `eligibility_empty` is fixed by connecting a provider, `direction_blocked` is not.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["eligibility_empty","direction_blocked"]},"detail":{"type":"string","description":"A sentence naming what to change. Every refusal this endpoint produces today carries one, but the type allows its absence, so branch on `error` and treat `detail` as display text."}},"required":["error"]}}}},"501":{"description":"A free-form description was sent to a deployment with no extractor wired. Send `{ intent: { ... } }` instead; the structured path is always available.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["nl_unavailable"]},"detail":{"type":"string","description":"A sentence naming what to change. Every refusal this endpoint produces today carries one, but the type allows its absence, so branch on `error` and treat `detail` as display text."}},"required":["error"]}}}},"502":{"description":"The extractor ran and produced no usable intent, or failed. Rephrasing may work; sending a structured intent always does.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["nl_extraction_failed"]},"detail":{"type":"string","description":"A sentence naming what to change. Every refusal this endpoint produces today carries one, but the type allows its absence, so branch on `error` and treat `detail` as display text."}},"required":["error"]}}}}}}},"/v1/connect/start":{"post":{"deprecated":true,"summary":"Begin the Connect Link OAuth flow for a provider","externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979 — canonical path and alias window"},"description":"DEPRECATED alias of `POST /v1/connections/start`, kept for two releases. The same handler is registered at both paths and both require the same grant, so switching the path changes nothing else. Build against the canonical path, which carries the full description of this flow.\n\nTWO DIFFERENT REDIRECT URIS ARE IN PLAY, and mixing them up is the usual first failure. The `redirect_uri` in the body is where OUR callback sends the browser once the flow finishes, and it is stored against the state token; it must be https. The `redirect_uri` embedded inside the returned `authorize_url` is our own callback, and that is the value the provider has to have allowlisted.\n\n`link_token` is the same value as the `state` parameter inside `authorize_url`. It is single use and expires at `expires_at`, ten minutes out.\n\nThe refusals are bare `{ error, ... }` bodies, not the nested envelope the rest of this document mostly uses. A provider with no OAuth configuration is 404. A missing platform client credential is 500 — that is our seeding defect, not a caller error, and no change to the request fixes it.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"server_id":{"type":"string","minLength":1,"maxLength":64},"user_id":{"type":"string","minLength":1,"maxLength":128},"redirect_uri":{"type":"string","format":"uri"},"scopes":{"type":"string","maxLength":2048}},"required":["server_id","user_id","redirect_uri"]}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"link_token":{"type":"string","description":"Single-use state token, also embedded in `authorize_url`."},"authorize_url":{"type":"string","format":"uri","description":"Send the user here. Carries the client id, our callback as its redirect_uri, the state token, response_type=code and the resolved scopes."},"expires_at":{"type":"string","format":"date-time","description":"Ten minutes after the call. A callback arriving later is refused."}},"required":["link_token","authorize_url","expires_at"]}}}},"400":{"description":"The body did not match the schema.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body"]},"issues":{"type":"array","items":{},"description":"Validation issues, one per rejected field."}},"required":["error","issues"]}}}},"404":{"description":"The provider has no OAuth configuration in the catalog.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["server_oauth_not_configured"]},"server_id":{"type":"string"}},"required":["error","server_id"]}}}},"500":{"description":"The platform's OAuth client credential is not seeded for this provider. A configuration defect on our side.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["oauth_client_not_configured"]},"message":{"type":"string"}},"required":["error","message"]}}}}}}},"/v1/tool-calls/{id}/payment-status":{"get":{"summary":"Where a tool call's payment got to","description":"The settlement state of the payment a tool call started, computed from the normalized provider events correlated to it. One shape for every payment rail: you do not branch on the provider.\n\nHOW THE CORRELATION WORKS, because it determines what you can expect back. The meta-tool router stamps one idempotency key per call and hands it to the provider — as a body field on some rails, as a header on others. When the provider later fires its webhook, the normalized event carries that key as `external_reference`, and this endpoint finds the events by matching on it. A tool call that never went through the router carries no key, so nothing can ever be correlated to it; that case is `unknown` rather than a `pending` that would have the agent poll forever.\n\nTHE STATUS IS THE HIGHEST-PRIORITY EVENT, NOT THE NEWEST. The order is `refunded` > `failed` > `succeeded` > `updated` > `pending`, and the winner is taken across the whole window regardless of when each event arrived. A refund that landed before a late-arriving success still reads `refunded`. That is deliberate — the worst outcome in the window is the one an agent must act on — and it means this field is NOT a replay of the provider's latest webhook.\n\n`updated` is the rail telling you something changed without saying what. Resolve it against the provider's own API; this endpoint cannot.\n\nWHAT `pending` AND `unknown` EACH MEAN, because they are not degrees of the same thing. `pending` means the call is correlatable and no settlement event has arrived — keep polling. `unknown` means the call carries no idempotency key, so no event will EVER correlate to it; polling is pointless. `unknown` is the only status that forces `idempotency_key` to null and `events` to empty, and it is never produced any other way.\n\nThe `events` array is the twenty most recent matching events, newest first. Twenty is a hard cap, not a page: there is no cursor, and a call with more than twenty events simply cannot show you the older ones from here.\n\nScoped to the credential's org AND project, with no way to widen it. A tool call that exists under another org, or under another project of the same org, is answered 404 exactly like one that never existed: existence is not probeable across the boundary.\n\nScope: `sessions:read`. This is a read of a session's own tool call, not of a wallet or a payment resource, and it needs no payment scope.","parameters":[{"schema":{"type":"string","description":"The tool call, as `tc_<digits>` or as the bare digits. Both are accepted and the response always echoes the prefixed spelling. Anything else — a UUID, a negative number, an empty segment — is refused 400 before any lookup, so a malformed id and an id belonging to another tenant are answered differently."},"required":true,"description":"The tool call, as `tc_<digits>` or as the bare digits. Both are accepted and the response always echoes the prefixed spelling. Anything else — a UUID, a negative number, an empty segment — is refused 400 before any lookup, so a malformed id and an id belonging to another tenant are answered differently.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"tool_call_id":{"type":"string","description":"Always `tc_`-prefixed, whichever spelling you sent."},"payment_status":{"type":"string","enum":["pending","succeeded","failed","refunded","updated","unknown"]},"idempotency_key":{"type":["string","null"],"description":"The key the router stamped on the call and the provider echoed back. Null exactly when `payment_status` is `unknown`."},"original_status":{"type":"string","description":"The tool call's OWN status — whether the call itself ran — which is a different question from whether the money settled. A call can be `succeeded` here and `failed` in `payment_status`."},"events":{"type":"array","items":{"type":"object","properties":{"event_type":{"type":"string","description":"The normalized type, always under `commerce.payment.`."},"received_at":{"type":"string","format":"date-time"},"provider":{"type":["string","null"],"description":"Read off the event payload; null when the provider did not name itself."},"provider_action":{"type":["string","null"]},"payment_id":{"type":["string","null"],"description":"The provider's own id for the payment, when the payload carried one."}},"required":["event_type","received_at","provider","provider_action","payment_id"]}}},"required":["tool_call_id","payment_status","idempotency_key","original_status","events"]}}}},"400":{"description":"Bad Request. The id is neither `tc_<digits>` nor bare digits. BARE body — there is no `error.code` to read and no `request_id`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_tool_call_id"]}},"required":["error"]}}}},"404":{"description":"Not Found. No tool call with this id is visible to this credential. BARE body.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["tool_call_not_found"]}},"required":["error"]}}}}}}},"/v1/tool-calls/{id}/payment-status/stream":{"get":{"summary":"The same payment status, pushed as it changes (SSE)","description":"Server-Sent Events over the same data, same auth and same scoping as the poll above. Every frame's payload is byte-identical to that endpoint's 200 body, so one parser serves both.\n\nTHE FRAME GRAMMAR. `event: snapshot` arrives once, immediately, and carries the same object the non-streaming poll returns — so you never need a GET before opening the stream. `event: update` carries that same object again whenever a new event lands or the computed status changes. `event: done` carries `{ reason, ... }` and is the last frame. Between frames the server writes SSE COMMENT lines (`: heartbeat <epoch-ms>`) every 15 seconds; every SSE parser ignores them, and they exist so a proxy or CDN does not close an idle connection.\n\nThe terminal set is `succeeded`, `failed` and `refunded`. `pending`, `updated` and `unknown` are NOT terminal, so a stream sitting on any of them stays open until you close it or the ceiling arrives.\n\nA STREAM OPENED ON AN UNCORRELATABLE CALL NEVER SAYS ANYTHING AGAIN. When the call carries no idempotency key the snapshot reads `unknown`, the poll returns without querying, and — because `unknown` is not terminal — the connection is held for the full 30 minutes emitting only heartbeats. Read the snapshot's status and close the stream yourself; do not wait.\n\nTHE FIVE `reason` VALUES, which are the whole set. `terminal` — a terminal status was reached and the server closed 5 seconds later, deliberately, so a client that reconnects on close does not race the final frame. `max_duration` — the connection hit its 30-minute ceiling; reopen it, nothing is wrong. `write_error` — a heartbeat could not be written. `client_disconnect` and `client_error` — your side went away, so you will not see them.\n\nA DATABASE HICCUP IS INVISIBLE TO YOU. The 1-second server-side poll swallows its own failures and tries again on the next tick: there is no error frame and the stream is not closed. Silence therefore means either nothing changed or the server could not tell — the two are indistinguishable from this endpoint, and the heartbeat proves only that the connection is alive.\n\nTHE REFUSALS ARE ORDINARY JSON, not SSE. Both the 400 and the 404 are decided before the stream headers are written, so a failing call answers `application/json` with a bare body and no frames at all. Do not attach an EventSource before checking the status.\n\nCOST, so you can size it. The server short-polls the events table once per second FOR EACH OPEN CONNECTION. Opening one stream per tool call across a large fleet is a query rate you should count before you build on it.\n\nScope: `sessions:read`, the same as the poll.","parameters":[{"schema":{"type":"string","description":"The tool call, as `tc_<digits>` or as the bare digits. Both are accepted and the response always echoes the prefixed spelling. Anything else — a UUID, a negative number, an empty segment — is refused 400 before any lookup, so a malformed id and an id belonging to another tenant are answered differently."},"required":true,"description":"The tool call, as `tc_<digits>` or as the bare digits. Both are accepted and the response always echoes the prefixed spelling. Anything else — a UUID, a negative number, an empty segment — is refused 400 before any lookup, so a malformed id and an id belonging to another tenant are answered differently.","name":"id","in":"path"}],"responses":{"200":{"description":"The stream opened. `snapshot` first, then `update` per change, then `done`; heartbeat comments in between. The connection is closed by the server 5 seconds after a terminal status, or at 30 minutes, whichever comes first.","content":{"text/event-stream":{"schema":{"type":"string"}}}},"400":{"description":"Bad Request. The id is neither `tc_<digits>` nor bare digits. BARE body — there is no `error.code` to read and no `request_id`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_tool_call_id"]}},"required":["error"]}}}},"404":{"description":"Not Found. No tool call with this id is visible to this credential. BARE body.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["tool_call_not_found"]}},"required":["error"]}}}}}}},"/v1/tool-calls/{id}/verification-status":{"get":{"summary":"Where a tool call's identity check got to","description":"The KYC analogue of the payment poll: after a verification tool call, the buyer completes a hosted flow (or a provider scores them server-side) and the provider's webhooks arrive asynchronously. This reads the latest known state so an agent can wait on it.\n\nHOW THE CORRELATION WORKS, because it determines what you can expect back. The meta-tool router stamps one idempotency key per call and hands it to the provider — as a body field on some rails, as a header on others. When the provider later fires its webhook, the normalized event carries that key as `external_reference`, and this endpoint finds the events by matching on it. A tool call that never went through the router carries no key, so nothing can ever be correlated to it; that case is `unknown` rather than a `pending` that would have the agent poll forever.\n\nTHE PRIORITY ORDER IS `approved` > `rejected` > `review` > `expired` > `pending`, taken across the window rather than by arrival time — the same rule as the payment poll, with one consequence worth stating: an `approved` that arrived before a later `rejected` still wins. If you need the provider's own chronology, read `events`, which is ordered.\n\n`review` MEANS A HUMAN, NOT A DELAY. The provider's automated layer declined to decide and the case is waiting on manual disposition. It is not terminal and it has no deadline of ours; an agent that treats it as a slow `pending` will wait indefinitely.\n\n`expired` is procedural, not a judgement: the buyer never finished the hosted flow inside the provider's own TTL. Start a new verification rather than re-polling this one.\n\n`pending` versus `unknown` splits exactly as on the payment poll: `pending` is correlatable and waiting, `unknown` carries no key and will never resolve.\n\nThe `events` array is the twenty most recent matching events, newest first. Twenty is a hard cap, not a page: there is no cursor, and a call with more than twenty events simply cannot show you the older ones from here.\n\nScoped to the credential's org AND project, with no way to widen it. A tool call that exists under another org, or under another project of the same org, is answered 404 exactly like one that never existed: existence is not probeable across the boundary.\n\nScope: `sessions:read`.","parameters":[{"schema":{"type":"string","description":"The tool call, as `tc_<digits>` or as the bare digits. Both are accepted and the response always echoes the prefixed spelling. Anything else — a UUID, a negative number, an empty segment — is refused 400 before any lookup, so a malformed id and an id belonging to another tenant are answered differently."},"required":true,"description":"The tool call, as `tc_<digits>` or as the bare digits. Both are accepted and the response always echoes the prefixed spelling. Anything else — a UUID, a negative number, an empty segment — is refused 400 before any lookup, so a malformed id and an id belonging to another tenant are answered differently.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"tool_call_id":{"type":"string","description":"Always `tc_`-prefixed, whichever spelling you sent."},"verification_status":{"type":"string","enum":["pending","approved","rejected","expired","review","unknown"]},"idempotency_key":{"type":["string","null"],"description":"Null exactly when `verification_status` is `unknown`."},"original_status":{"type":"string","description":"Whether the tool CALL ran, which is a different question from whether the buyer was verified."},"hosted_url":{"type":["string","null"],"description":"The buyer-facing verification link, read off the originating call's own output so you can re-show it while polling. Null on rails that score server-side and have no buyer-facing flow, and null when the output carried no such field — the two are not distinguishable here. Unlike `idempotency_key`, this field is present even on an `unknown` status."},"events":{"type":"array","items":{"type":"object","properties":{"event_type":{"type":"string","description":"The normalized type, always under `commerce.kyc.`."},"received_at":{"type":"string","format":"date-time"},"provider":{"type":["string","null"]},"verification_id":{"type":["string","null"],"description":"The provider's own id for the inquiry, when the payload carried one."}},"required":["event_type","received_at","provider","verification_id"]}}},"required":["tool_call_id","verification_status","idempotency_key","original_status","hosted_url","events"]}}}},"400":{"description":"Bad Request. The id is neither `tc_<digits>` nor bare digits. BARE body — there is no `error.code` to read and no `request_id`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_tool_call_id"]}},"required":["error"]}}}},"404":{"description":"Not Found. No tool call with this id is visible to this credential. BARE body.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["tool_call_not_found"]}},"required":["error"]}}}}}}},"/v1/tool-calls/{id}/verification-status/stream":{"get":{"summary":"The same verification status, pushed as it changes (SSE)","description":"Server-Sent Events over the verification poll's data, with the same auth, the same scoping and the same payload per frame.\n\nTHE FRAME GRAMMAR. `event: snapshot` arrives once, immediately, and carries the same object the non-streaming poll returns — so you never need a GET before opening the stream. `event: update` carries that same object again whenever a new event lands or the computed status changes. `event: done` carries `{ reason, ... }` and is the last frame. Between frames the server writes SSE COMMENT lines (`: heartbeat <epoch-ms>`) every 15 seconds; every SSE parser ignores them, and they exist so a proxy or CDN does not close an idle connection.\n\nTHE TERMINAL SET IS `approved`, `rejected` AND `expired`. `review` IS NOT IN IT, and that is the difference from every other stream on this surface: a case sent to manual disposition holds the connection open, because the disposition can still change the answer. Since a manual review routinely outlasts the 30-minute ceiling, expect `done` with `max_duration` on those cases and reconnect. `pending` and `unknown` are likewise not terminal.\n\nA STREAM OPENED ON AN UNCORRELATABLE CALL NEVER SAYS ANYTHING AGAIN — same as the payment stream: `unknown` in the snapshot, then heartbeats until the ceiling. Close it yourself.\n\nTHE FIVE `reason` VALUES, which are the whole set. `terminal` — a terminal status was reached and the server closed 5 seconds later, deliberately, so a client that reconnects on close does not race the final frame. `max_duration` — the connection hit its 30-minute ceiling; reopen it, nothing is wrong. `write_error` — a heartbeat could not be written. `client_disconnect` and `client_error` — your side went away, so you will not see them.\n\nThe `done` frame's second field is `verification_status`, not `payment_status`; everything else about the frame is identical.\n\nA DATABASE HICCUP IS INVISIBLE TO YOU. The 1-second server-side poll swallows its own failures and tries again on the next tick: there is no error frame and the stream is not closed. Silence therefore means either nothing changed or the server could not tell — the two are indistinguishable from this endpoint, and the heartbeat proves only that the connection is alive.\n\nTHE REFUSALS ARE ORDINARY JSON, not SSE. Both the 400 and the 404 are decided before the stream headers are written, so a failing call answers `application/json` with a bare body and no frames at all. Do not attach an EventSource before checking the status.\n\nScope: `sessions:read`.","parameters":[{"schema":{"type":"string","description":"The tool call, as `tc_<digits>` or as the bare digits. Both are accepted and the response always echoes the prefixed spelling. Anything else — a UUID, a negative number, an empty segment — is refused 400 before any lookup, so a malformed id and an id belonging to another tenant are answered differently."},"required":true,"description":"The tool call, as `tc_<digits>` or as the bare digits. Both are accepted and the response always echoes the prefixed spelling. Anything else — a UUID, a negative number, an empty segment — is refused 400 before any lookup, so a malformed id and an id belonging to another tenant are answered differently.","name":"id","in":"path"}],"responses":{"200":{"description":"The stream opened. `snapshot`, then `update` per change, then `done`, with heartbeat comments in between. Closed 5 seconds after `approved`, `rejected` or `expired`, or at 30 minutes.","content":{"text/event-stream":{"schema":{"type":"string"}}}},"400":{"description":"Bad Request. The id is neither `tc_<digits>` nor bare digits. BARE body — there is no `error.code` to read and no `request_id`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_tool_call_id"]}},"required":["error"]}}}},"404":{"description":"Not Found. No tool call with this id is visible to this credential. BARE body.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["tool_call_not_found"]}},"required":["error"]}}}}}}},"/v1/wallets/{id}/receive":{"get":{"summary":"How to put money into a wallet, per rail","description":"Where to send funds for one currency of one wallet. The answer is rail-shaped, and only one rail has an address.\n\nUSDC RETURNS A REAL ON-CHAIN ADDRESS and is the only currency that does. The address is resolved live from the custody provider for the consumer this wallet belongs to, so it is the same address the onramp delivers to and the same one the custody comparison is measured against. `network` follows the CREDENTIAL's environment, not a parameter: a live key gets `base`, a test key gets `base-sepolia`. Sending Base USDC to the address a test key returned is a transfer to a testnet address and the money does not exist on mainnet.\n\nEVERY OTHER CURRENCY RETURNS GUIDANCE, NOT AN ADDRESS. `address`, `network` and `asset_contract` are all null and `note` is a sentence for a human, because the deposit path on those rails is a funding source rather than a destination you can hand out. BRL reads `pix`, USD reads `wire`, and anything else reads `unknown` — including a currency this deployment does not support at all, which is answered 200 with `unknown` rather than refused. `rail: \"unknown\"` is the signal that there is nothing to act on.\n\nTHE FIAT PATH TOUCHES NO EXTERNAL SERVICE, so it cannot produce the 409 or the 502 below. Those two are reachable only on USDC.\n\n`currency` is upper-cased before it is matched, so `usdc` and `USDC` are the same request. Omitting it means USDC.\n\nScope: `wallets:read`. Wallets outside the credential's org and project are 404, never 403.","parameters":[{"schema":{"type":"string","description":"The wallet id."},"required":true,"description":"The wallet id.","name":"id","in":"path"},{"schema":{"type":"string","description":"Case-insensitive. Defaults to `USDC`, the only rail with an address."},"required":false,"description":"Case-insensitive. Defaults to `USDC`, the only rail with an address.","name":"currency","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"currency":{"type":"string"},"rail":{"type":"string","enum":["onchain","pix","wire","unknown"],"description":"`onchain` only for USDC. `unknown` means no deposit path is defined."},"network":{"type":["string","null"],"enum":["base","base-sepolia"],"description":"Non-null only on `onchain`, and decided by the credential's environment rather than by anything you send."},"address":{"type":["string","null"],"description":"The Base address to send USDC to. Null on every fiat rail."},"asset_contract":{"type":["string","null"],"description":"The USDC ERC-20 contract on `network`. Check it before sending: it is what distinguishes real USDC from a token that merely calls itself that."},"note":{"type":"string","description":"One sentence a dashboard can render as-is. Prose, not a code to branch on."}},"required":["currency","rail","network","address","asset_contract","note"]}}}},"404":{"description":"Not Found. No wallet with this id is visible to this credential.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"Conflict. `no_onchain_address`: USDC was asked for on a wallet that is not consumer-scoped, so there is no consumer whose custody address could be resolved. Org and seller wallets are always in this state. Terminal for the request — retrying cannot change it.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["no_onchain_address"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"502":{"description":"Bad Gateway. `cdp_unavailable`: the custody provider could not be reached or refused, so no address could be resolved. `error.details.detail` carries the underlying failure as free text for an operator. Transient — retry.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["cdp_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/wallets/{id}/custody":{"get":{"summary":"What the ledger attributed against what the address actually holds","description":"Two numbers and the gap between them: what this wallet's ledger says is spendable, and what the on-chain address holds right now. They are ALLOWED to differ — custody is the truth about what money exists, the ledger is the truth about what it means — and the difference is information rather than a fault.\n\nWHY IT IS A SEPARATE CALL and not part of the wallet read: answering it requires a live chain round trip, and folding that into the read a dashboard makes constantly would put an external network hop in front of every wallet render. Here, a slow or unreachable chain costs one panel instead of the whole wallet.\n\nREAD `state`, NOT THE SIGN OF THE DIFFERENCE. `reconciled` — they agree exactly. `unattributed_credit` — the chain holds MORE than the ledger attributed, which is what money sent straight to the address looks like before anyone claims it. It is benign and it is never auto-credited into the spendable balance. `ledger_exceeds_custody` — the ledger claims more than the address holds. That is the direction that hurts: a spend authorized against it can fail at settlement, after the buyer has been told yes.\n\nEVERY AMOUNT IS ATOMIC USDC (six decimals) AND ARRIVES AS A DECIMAL STRING, because the values exceed what a double represents exactly. `1000000` is one dollar. Do not read them as cents: that is off by a factor of ten thousand, and this lane meters sub-cent amounts where the rounding would erase the money the endpoint exists to account for. `difference_atomic` is `onchain - ledger`, SIGNED and never clamped, so the dangerous direction survives to the screen.\n\nA CHAIN THAT CANNOT BE READ IS A 502, NOT A LEDGER FIGURE. Falling back to the ledger alone would be exactly the single quietly-wrong number this endpoint exists to stop showing, so it is not done. Render the gap as unknown.\n\n`observed_at` timestamps the chain read, not the ledger read.\n\nScope: `wallets:read`.","parameters":[{"schema":{"type":"string","description":"The wallet id."},"required":true,"description":"The wallet id.","name":"id","in":"path"},{"schema":{"type":"string","description":"Case-insensitive, and `USDC` is the only accepted value. Anything else is 409, not 400 — see below."},"required":false,"description":"Case-insensitive, and `USDC` is the only accepted value. Anything else is 409, not 400 — see below.","name":"currency","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"wallet_id":{"type":"string"},"currency":{"type":"string","enum":["USDC"]},"address":{"type":"string","description":"The same address `GET /v1/wallets/{id}/receive` hands out. That symmetry is the point."},"network":{"type":"string","enum":["base","base-sepolia"],"description":"From the credential's environment: live is `base`, test is `base-sepolia`."},"observed_at":{"type":"string","format":"date-time","description":"When the chain was read."},"ledger_atomic":{"type":"string","description":"What the ledger says is spendable. Atomic USDC."},"onchain_atomic":{"type":"string","description":"What the address holds. Atomic USDC."},"difference_atomic":{"type":"string","description":"`onchain_atomic - ledger_atomic`. Signed; a leading `-` is the dangerous case."},"state":{"type":"string","enum":["reconciled","unattributed_credit","ledger_exceeds_custody"]},"note":{"type":"string","description":"One rendered sentence describing `state`, with the difference already formatted, so the API and a dashboard cannot drift into describing the same state differently. Prose, not a code to branch on."}},"required":["wallet_id","currency","address","network","observed_at","ledger_atomic","onchain_atomic","difference_atomic","state","note"]}}}},"404":{"description":"Not Found. No wallet with this id is visible to this credential.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"Conflict, and the two codes need different actions. `no_custody_view`: a currency other than USDC was asked for — there is no custody comparison for a fiat rail, and `error.details.currency` echoes what was asked. `no_onchain_address`: the wallet is not consumer-scoped, so there is no address to compare against. Both are terminal for the request.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["no_custody_view","no_onchain_address"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"502":{"description":"Bad Gateway, and the two codes fail at different steps. `cdp_unavailable`: the address itself could not be resolved, with the underlying failure in `error.details.detail`. `chain_unavailable`: the address resolved but its balance could not be read, with `error.details.address` and `error.details.network` naming what was being read. Both are transient.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["cdp_unavailable","chain_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/wallets/{id}/transfer":{"post":{"summary":"Withdraw from a consumer wallet under the consumer's signed mandate","description":"Moves money OUT of a consumer-scoped wallet — USDC to a Base address, BRL to a Pix key. Hold, then send, then debit; on a send failure the hold is released.\n\nTHE MONEY IS AUTHORIZED BY THE CONSUMER'S SIGNED MANDATE, NOT BY WHO IS CALLING. `mandate`, `signature`, `agent_id` and `purpose` are all four required in practice: the schema marks them optional only so that omitting them produces a typed `mandate_required` refusal instead of a shapeless 400. No key, no role and no header substitutes for them, and the attribution recorded on the withdrawal is derived from the verified mandate rather than from anything the caller wrote. The destination must be NAMED in the mandate's signed withdrawal allowlist — its own field, separate from the list of merchants the agent may pay, so that a shopping grant is not also a cash-out grant.\n\nAND YET THE PATH DOES READ AN IDENTITY HEADER, BEFORE ANY OF THAT. The route carries an `admin` role gate as a preHandler, and that gate reads `x-codespar-user` and answers 403 on its own, before the mandate is looked at. Send the header, naming a member who holds `admin` or `owner` in the organization. On a dashboard-forwarded credential a missing header is refused immediately. On an API key the gate is being rolled out and currently lets the request through while recording it, so a key that works today without the header will stop working when the rollout completes — this is the one sentence to act on if you are integrating now.\n\nRead the two gates as two different questions. The role gate asks who may ASK for a withdrawal; the mandate asks whether the money's owner AGREED to it. Passing the first does not weaken the second.\n\nA THIRD GATE RUNS AFTER THE MANDATE: the same policy engine that governs agent spend, under the tool name `wallet:withdraw`. An organization rule targeting wallet tools therefore applies to money leaving a wallet, and can refuse or route the withdrawal to an approval even when the consumer's mandate is perfectly valid.\n\nIDEMPOTENCY, AND THE TWO WAYS IT REFUSES. Send `idempotency_key` and a replay returns the original 201 with the original `tx_hash`. A key whose withdrawal is still in flight is 409 `withdrawal_in_progress`; a key whose withdrawal FAILED is 409 `withdrawal_failed` and is permanently spent — retry under a NEW key. Without a key there is no replay protection at all and a repeated request sends twice.\n\n`amount_minor` is an integer in the currency's minor unit as the ROUTE reads it; the mandate's caps may be accounted in the currency's native unit, and the conversion between the two is done for you.\n\nTHE 403 ARRIVES IN THREE DIFFERENT BODY SHAPES depending on which gate refused — see that response's own description. This is the one status on this operation where a client cannot assume the envelope.\n\nScope: `wallets:admin`. Org and seller wallets never reach any of this: they are refused 409 `no_onchain_wallet` before the mandate is read.","parameters":[{"schema":{"type":"string","description":"The wallet id. Must be consumer-scoped."},"required":true,"description":"The wallet id. Must be consumer-scoped.","name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"currency":{"type":"string","minLength":1},"to_address":{"type":"string","minLength":1,"maxLength":64},"amount_minor":{"type":"integer","exclusiveMinimum":0},"idempotency_key":{"type":"string","minLength":1,"maxLength":128},"mandate":{},"signature":{"type":"string","minLength":64,"maxLength":64,"pattern":"^[0-9a-f]+$"},"agent_id":{"type":"string","minLength":1,"maxLength":200},"purpose":{"type":"string","minLength":1,"maxLength":120}},"required":["currency","to_address","amount_minor"]}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"withdrawal_id":{"type":"string","description":"`wtd_`-prefixed. The handle for reconciliation."},"tx_hash":{"type":"string","description":"The on-chain transaction hash for USDC, or the Pix settlement id for BRL. One field, two meanings, decided by `currency`."},"status":{"type":"string","enum":["sent"],"description":"The only value a 201 carries. It means the send was accepted by the rail, not that it has finally settled — for USDC that is a broadcast, not a confirmation depth."},"network":{"type":"string","description":"`base` or `base-sepolia` for USDC; `pix` for BRL."},"to_address":{"type":"string"},"amount_minor":{"type":"integer"},"currency":{"type":"string"}},"required":["withdrawal_id","tx_hash","status","network","to_address","amount_minor","currency"]}}}},"400":{"description":"Bad Request. `invalid_body` when the request does not match the schema, with the Zod issues in `error.details.issues`. `invalid_mandate` when the mandate object carries no consumer or no nonce, and `invalid_payload` when the verifier cannot read it at all. The four withdrawal-side codes reject the destination or the amount before any money moves: `unsupported_currency`, `invalid_amount`, `invalid_address` (USDC) and `invalid_pix_key` (BRL).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body","invalid_mandate","invalid_payload","unsupported_currency","invalid_amount","invalid_address","invalid_pix_key"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"401":{"description":"Unauthorized, and it is about the MANDATE, not about your credential — your credential was already accepted to reach this handler. `mandate_proof_invalid`: no stored mandate matches this payload and signature. `bad_signature`: the verifier rejected the signature over the presented payload. Neither is retryable without a correctly signed mandate.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["mandate_proof_invalid","bad_signature"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"403":{"description":"Forbidden, from one of THREE gates, in THREE different body shapes. Parse defensively: `error` is a string on two of them and an object on the third.\n\n(a) THE ROLE GATE, bare body: `{ error, required: \"admin\", message?, status? }`. `error` is `insufficient_role` when the acting member is not senior enough, `bearer_admin_role_missing` when no `x-codespar-user` was forwarded, `bearer_admin_user_not_member` when the forwarded user is not in the organization, or `bearer_admin_role_unresolved` when the role could not be looked up. The last three carry a `message` and `status`.\n\n(b) THE MANDATE GATE, `apiError` envelope. `mandate_required` (one of the four proof fields is missing), `mandate_consumer_mismatch`, `mandate_currency_mismatch`, `mandate_<status>` for a mandate that is not active, `mandate_sig_invalid` (the STORED row failed its own integrity check), `expired`, `agent_mismatch`, `purpose_mismatch`, `per_tx_cap_exceeded`, `total_cap_exceeded`, `currency_not_authorized`, `merchant_not_allowed`, and `withdrawal_destination_unpinned` — which carries `error.details` naming what the mandate DID authorize, so an agent can open a re-consent flow rather than stopping at a refusal it cannot interpret.\n\n(c) THE POLICY GATE, bare body: `{ error: \"policy_denied\", reason, ruleType, ruleId }`, each of the last three nullable. When the matched rule requires an approval the body also carries `approval_id` and `expires_at`; their presence is the signal that the withdrawal is pending a human rather than refused.\n\n`total_cap_exceeded` appears under (b) but is emitted from TWO places — the mandate verifier and the withdrawal executor's own cap check against what the mandate has already spent. The code and the envelope are the same either way; do not read it as proof of which check ran.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"string","enum":["insufficient_role","bearer_admin_role_missing","bearer_admin_user_not_member","bearer_admin_role_unresolved"]},"required":{"type":"string","enum":["admin"]},"message":{"type":"string"},"status":{"type":"number","enum":[403]}},"required":["error","required"]},{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"]}},"required":["error","request_id"]},{"type":"object","properties":{"error":{"type":"string","enum":["policy_denied"]},"reason":{"type":["string","null"]},"ruleType":{"type":["string","null"]},"ruleId":{"type":["string","null"]},"approval_id":{"type":"string"},"expires_at":{"type":["string","null"]}},"required":["error","reason","ruleType","ruleId"]}]}}}},"404":{"description":"Not Found, `not_found`, for two different misses that are deliberately indistinguishable: no such wallet for this credential, or a mandate belonging to another organization. Another tenant's mandate is invisible rather than forbidden, so its existence cannot be probed.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"Conflict. `no_onchain_wallet`: the wallet is not consumer-scoped and has no withdrawal path at all. `no_funding_source`: the rail has no active funding source to cash out through. `withdrawal_in_progress` and `withdrawal_failed`: the idempotency key is already spent — see the note above. `insufficient_funds`: the wallet does not hold the amount, caught by the balance invariant rather than by a pre-check, so it can also surface after the hold is attempted. `no_default_project`: the paying organization has no default project for the mandate to settle into.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["no_onchain_wallet","no_funding_source","withdrawal_in_progress","withdrawal_failed","insufficient_funds","no_default_project"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"413":{"description":"Payload Too Large, from the policy gate rather than from a body-size limit on this route. BARE body.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["payload_too_large"]}},"required":["error"]}}}},"422":{"description":"Unprocessable. `insufficient_gas`: the custody account cannot pay the network fee for this send. Nothing about the request is wrong and nothing has moved; it is an operational condition on our side.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["insufficient_gas"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"502":{"description":"Bad Gateway. `send_failed`: the rail refused or failed the send. The hold is released, so the balance is restored, and the withdrawal row is marked failed — which means the idempotency key, if you sent one, is now permanently spent and a retry needs a new one.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["send_failed"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"503":{"description":"Service Unavailable, and this status is the one place where the SAME code arrives in two different shapes. `policy_engine_error` comes in the `apiError` envelope when the engine threw, and in a BARE `{ error: \"policy_engine_error\" }` when the engine answered with that reason instead. Either way it is fail-closed: nothing has moved. `mandate_secret_unavailable` and `secret_unavailable` mean the consumer's signing secret could not be read, so the mandate could be neither confirmed nor rejected; they always use the envelope. All three are transient — retry.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["policy_engine_error","mandate_secret_unavailable","secret_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"]}},"required":["error","request_id"]},{"type":"object","properties":{"error":{"type":"string","enum":["policy_engine_error"]}},"required":["error"]}]}}}}}}},"/v1/wallets/{id}/statement-import":{"post":{"summary":"Hand the reconciler a bank statement","description":"Feeds normalized bank-statement lines in so the next reconciliation cycle can match them against this wallet's ledger. It records what the bank says happened; it does not move money and it does not post ledger entries.\n\n`provider_event_id` IS THE WHOLE MATCH KEY. It must equal the ledger row's `external_ref` — the Pix end-to-end id, the SPI identifier, the Open Finance transaction id, whatever the bank stamped. Every other field on an entry is for reporting: the matcher reads only this one. An entry whose id matches nothing is accepted, counted as imported, and simply never matches.\n\nIMPORTING THE SAME STATEMENT TWICE IS SAFE, and that is what the response's two counters are for. Deduplication is on `(source, provider_event_id)` within the project, so `imported` counts the entries that were new and `duplicates` counts the ones already held. The two always sum to the number of entries you sent, so a shortfall means you miscounted your own batch, not that anything was dropped. Re-uploading a whole statement to pick up a few new lines is the intended usage.\n\nEACH ENTRY BECOMES A `commerce.payment.received` EVENT, published through the ordinary event path rather than written straight to storage. That matters because it means your webhook subscriptions fire for imported statement credits exactly as they do for provider-delivered ones. The fan-out is fire-and-forget, so a slow subscriber of yours cannot stall the import.\n\n`occurred_at` KEEPS THE BANK'S OWN TIMESTAMP on the stored event, which is what makes the reconciler's cutoffs and ordering behave. Omitting it means the event is stamped on arrival, which will misplace a back-dated statement inside those windows.\n\n`source` names where the statement came from, lowercase kebab-case only, and it is half the dedup key: importing the same bank line under two different sources stores it twice.\n\nTHE 202 IS AN ACKNOWLEDGEMENT, NOT A RECONCILIATION. The entries are stored; the matching happens on the next cycle, and `next_recon_cycle_within_seconds` is how long that is at most. Nothing here reports a match.\n\nScope: `wallets:admin`, and the route additionally requires the `admin` role: send `x-codespar-user` naming an admin or owner of the organization.","parameters":[{"schema":{"type":"string","description":"The wallet whose ledger these lines will be matched against."},"required":true,"description":"The wallet whose ledger these lines will be matched against.","name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"source":{"type":"string","minLength":1,"maxLength":64,"pattern":"^[a-z0-9-]+$"},"entries":{"type":"array","items":{"type":"object","properties":{"provider_event_id":{"type":"string","minLength":1,"maxLength":256},"amount_minor":{"type":"integer"},"currency":{"type":"string","minLength":3,"maxLength":8},"occurred_at":{"type":"string","format":"date-time"},"memo":{"type":"string","maxLength":512}},"required":["provider_event_id","amount_minor","currency"]},"minItems":1,"maxItems":1000}},"required":["source","entries"]}}}},"responses":{"202":{"description":"Accepted. Every entry has been stored or recognized as already stored. Reconciliation has not run yet.","content":{"application/json":{"schema":{"type":"object","properties":{"wallet_id":{"type":"string"},"source":{"type":"string","description":"Echoes what you sent."},"imported":{"type":"integer","description":"Entries that were new."},"duplicates":{"type":"integer","description":"Entries already held under this `(source, provider_event_id)`."},"next_recon_cycle_within_seconds":{"type":"integer","description":"An upper bound on the wait until matching runs — currently 60. A constant of the deployment, not a per-request estimate, and not a promise that a match will be found."}},"required":["wallet_id","source","imported","duplicates","next_recon_cycle_within_seconds"]}}}},"400":{"description":"Bad Request. `invalid_body`, with the Zod issues in `error.details.issues`. The batch is all-or-nothing at this step: one malformed entry refuses the whole request and nothing is stored.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"403":{"description":"Forbidden, from the role gate, in a BARE body — not the `apiError` envelope the 400 and 404 use. `insufficient_role` when the acting member is not senior enough; on an API key, `bearer_admin_role_missing`, `bearer_admin_user_not_member` or `bearer_admin_role_unresolved`, each with a `message` and `status`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["insufficient_role","bearer_admin_role_missing","bearer_admin_user_not_member","bearer_admin_role_unresolved"]},"required":{"type":"string","enum":["admin"]},"message":{"type":"string"},"status":{"type":"number","enum":[403]}},"required":["error","required"]}}}},"404":{"description":"Not Found. No wallet with this id is visible to this credential.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/audit/events":{"get":{"summary":"Browse the audit chain (alias path)","description":"A read-only page of the caller's own tamper-evident chain, newest first.\n\nTHIS IS AN ALIAS OF `GET /v1/audit-events`, NOT A SECOND RESOURCE. One handler serves both paths, so the query, the body and every refusal are identical and there is nothing to choose between them. It is described separately only because a generated client sees two operations; pick one spelling and stay on it. Note that the OTHER audit operations — the chain's health report, its incidents, its configuration — have no twin under this prefix and live only under `/v1/audit-events`.\n\nBROWSE, NOT EXPORT. This emits no manifest, no signature and no chain entry of its own, so paging through it leaves no trace and proves nothing offline. It is for showing an operator what happened, not for handing an auditor evidence.\n\nScoped to the credential's organization with no way to widen it, and there is no cross-org refusal to handle because there is no organization segment in the path to disagree with.\n\n`event_type` IS AN EXACT MATCH UNLESS IT ENDS IN A DOT, in which case it is a prefix match — so `tool_call.` returns the succeeded and the failed variants in one pass without two queries. The default window is the last seven days.\n\nPAGINATION WALKS BACKWARDS. Pass the previous response's `next_before_sequence` as `before_sequence`. The cursor is null only when the page came back SHORTER than `limit`, so a full page that happens to end at the chain's floor still hands you a non-null cursor and the last call is the one that returns fewer rows. An out-of-range `limit` is clamped rather than refused.\n\nScope: `audit:read`.","parameters":[{"schema":{"type":"string","description":"Exact match, or a prefix match when it ends with a dot."},"required":false,"description":"Exact match, or a prefix match when it ends with a dot.","name":"event_type","in":"query"},{"schema":{"type":"string","format":"date-time","description":"ISO 8601. Defaults to seven days ago."},"required":false,"description":"ISO 8601. Defaults to seven days ago.","name":"from","in":"query"},{"schema":{"type":"string","format":"date-time","description":"ISO 8601. Defaults to now."},"required":false,"description":"ISO 8601. Defaults to now.","name":"to","in":"query"},{"schema":{"type":"integer","description":"Clamped to 1..200. Defaults to 50, and falls back to 50 when the value is 0 or is not a number; a negative value becomes 1."},"required":false,"description":"Clamped to 1..200. Defaults to 50, and falls back to 50 when the value is 0 or is not a number; a negative value becomes 1.","name":"limit","in":"query"},{"schema":{"type":"integer","description":"Returns entries strictly below this sequence. Use the previous page's `next_before_sequence`."},"required":false,"description":"Returns entries strictly below this sequence. Use the previous page's `next_before_sequence`.","name":"before_sequence","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"events":{"type":"array","items":{"type":"object","properties":{"sequence_number":{"type":"integer","description":"Per-org, gap-free and never reused. This is the chain's ordering."},"event_type":{"type":"string"},"happened_at":{"type":"string","format":"date-time"},"payload":{"type":"object","additionalProperties":{}},"prev_hash":{"type":"string","description":"The previous entry's `entry_hash`, which is what binds this row to the one before it. Never null: the column is NOT NULL and the first entry of a chain carries the genesis value instead."},"entry_hash":{"type":"string"}},"required":["sequence_number","event_type","happened_at","payload","prev_hash","entry_hash"]}},"next_before_sequence":{"type":["integer","null"],"description":"The lowest sequence on this page when the page was full; null when it was not."}},"required":["events","next_before_sequence"]}}}},"400":{"description":"Bad Request, in a BARE body. `invalid_before_sequence` when the cursor is not a number, `invalid_iso_8601` when `from` or `to` is not a date this server can parse, `from_after_to` when the window is inverted.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_before_sequence","invalid_iso_8601","from_after_to"]}},"required":["error"]}}}}}}},"/v1/counterparties":{"post":{"externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979: canonical path and alias window"},"summary":"Register a counterparty, or merge into the one already there","description":"An UPSERT when `identifier` and `identifier_kind` are both supplied, keyed on that pair within the caller's org AND project. Registering the same CNPJ a second time from a different agent run in the same project does not create a second row: `name` is replaced, `last_seen_at` is bumped to now, and `metadata` is merged over the stored object at the top level only, so a nested object is replaced wholesale rather than deep merged. Two different projects that both register that CNPJ keep two rows and two separate histories.\n\nOmit both identifier fields and there is no dedup key, so every call creates a new row. The two fields travel together: one without the other is 400.\n\nThe status is 201 on both paths, including the merge. A 201 here is not a promise that a row was created.\n\nThe canonical path. `POST /v1/commerce-memory/counterparties` is the deprecated alias of this operation and is kept for two releases. The two paths are the same handler with the same required scope, so switching one for the other changes nothing about the request, the response or the credential.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"kind":{"type":"string","enum":["supplier","customer","agent_peer"]},"name":{"type":"string","minLength":1,"maxLength":256},"identifier":{"type":"string","minLength":1,"maxLength":256,"description":"Set it together with `identifier_kind` or omit both. One without the other is refused."},"identifier_kind":{"type":"string","enum":["cnpj","cpf","email","phone","wallet_address","other"]},"metadata":{"type":"object","additionalProperties":{}}},"required":["kind","name"]}}}},"responses":{"201":{"description":"Created, or merged into the existing row.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`cp_` prefixed."},"kind":{"type":"string","enum":["supplier","customer","agent_peer"]},"name":{"type":"string"},"identifier":{"type":["string","null"],"description":"Null when the counterparty was registered without one."},"identifier_kind":{"type":["string","null"],"enum":["cnpj","cpf","email","phone","wallet_address","other"]},"metadata":{"type":"object","additionalProperties":{}},"created_at":{"type":"string","format":"date-time"},"last_seen_at":{"type":"string","format":"date-time","description":"Bumped by a re-registration of the same identifier and by every interaction written against this counterparty."}},"required":["id","kind","name","identifier","identifier_kind","metadata","created_at","last_seen_at"]}}}},"400":{"description":"`invalid_body`. `details.issues` carries the validator's own issue list, including the case where only one of `identifier` / `identifier_kind` was sent.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"get":{"externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979: canonical path and alias window"},"summary":"List this project's counterparties","description":"Most recently seen first, capped by `limit`. There is NO CURSOR: a project with more counterparties than `limit` (max 200) cannot be walked past the first page from here.\n\n`identifier` and `identifier_kind` filter only when BOTH are present. Either one on its own is accepted by the validator and then silently ignored, so a lookup by bare `identifier` returns the unfiltered page rather than an error or an empty list.\n\nThe canonical path. `GET /v1/commerce-memory/counterparties` is the deprecated alias of this operation and is kept for two releases. The two paths are the same handler with the same required scope, so switching one for the other changes nothing about the request, the response or the credential.","parameters":[{"schema":{"type":"string","enum":["supplier","customer","agent_peer"]},"required":false,"name":"kind","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"maxLength":256},"required":false,"name":"identifier","in":"query"},{"schema":{"type":"string","enum":["cnpj","cpf","email","phone","wallet_address","other"]},"required":false,"name":"identifier_kind","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"counterparties":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"`cp_` prefixed."},"kind":{"type":"string","enum":["supplier","customer","agent_peer"]},"name":{"type":"string"},"identifier":{"type":["string","null"],"description":"Null when the counterparty was registered without one."},"identifier_kind":{"type":["string","null"],"enum":["cnpj","cpf","email","phone","wallet_address","other"]},"metadata":{"type":"object","additionalProperties":{}},"created_at":{"type":"string","format":"date-time"},"last_seen_at":{"type":"string","format":"date-time","description":"Bumped by a re-registration of the same identifier and by every interaction written against this counterparty."}},"required":["id","kind","name","identifier","identifier_kind","metadata","created_at","last_seen_at"]}}},"required":["counterparties"]}}}},"400":{"description":"`invalid_query`, with the validator's issue list under `details.issues`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_query"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/commerce-memory/counterparties":{"post":{"deprecated":true,"externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979: canonical path and alias window"},"summary":"Register a counterparty, or merge into the one already there (deprecated path)","description":"DEPRECATED alias of `POST /v1/counterparties` (ent#979), described in this document under that path and kept here for two releases. The two paths are the same handler with the same required scope, so switching one for the other changes nothing about the request, the response or the credential.\n\nAn UPSERT when `identifier` and `identifier_kind` are both supplied, keyed on that pair within the caller's org AND project. Registering the same CNPJ a second time from a different agent run in the same project does not create a second row: `name` is replaced, `last_seen_at` is bumped to now, and `metadata` is merged over the stored object at the top level only, so a nested object is replaced wholesale rather than deep merged. Two different projects that both register that CNPJ keep two rows and two separate histories.\n\nOmit both identifier fields and there is no dedup key, so every call creates a new row. The two fields travel together: one without the other is 400.\n\nThe status is 201 on both paths, including the merge. A 201 here is not a promise that a row was created.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"kind":{"type":"string","enum":["supplier","customer","agent_peer"]},"name":{"type":"string","minLength":1,"maxLength":256},"identifier":{"type":"string","minLength":1,"maxLength":256,"description":"Set it together with `identifier_kind` or omit both. One without the other is refused."},"identifier_kind":{"type":"string","enum":["cnpj","cpf","email","phone","wallet_address","other"]},"metadata":{"type":"object","additionalProperties":{}}},"required":["kind","name"]}}}},"responses":{"201":{"description":"Created, or merged into the existing row.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`cp_` prefixed."},"kind":{"type":"string","enum":["supplier","customer","agent_peer"]},"name":{"type":"string"},"identifier":{"type":["string","null"],"description":"Null when the counterparty was registered without one."},"identifier_kind":{"type":["string","null"],"enum":["cnpj","cpf","email","phone","wallet_address","other"]},"metadata":{"type":"object","additionalProperties":{}},"created_at":{"type":"string","format":"date-time"},"last_seen_at":{"type":"string","format":"date-time","description":"Bumped by a re-registration of the same identifier and by every interaction written against this counterparty."}},"required":["id","kind","name","identifier","identifier_kind","metadata","created_at","last_seen_at"]}}}},"400":{"description":"`invalid_body`. `details.issues` carries the validator's own issue list, including the case where only one of `identifier` / `identifier_kind` was sent.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"get":{"deprecated":true,"externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979: canonical path and alias window"},"summary":"List this project's counterparties (deprecated path)","description":"DEPRECATED alias of `GET /v1/counterparties` (ent#979), described in this document under that path and kept here for two releases. The two paths are the same handler with the same required scope, so switching one for the other changes nothing about the request, the response or the credential.\n\nMost recently seen first, capped by `limit`. There is NO CURSOR: a project with more counterparties than `limit` (max 200) cannot be walked past the first page from here.\n\n`identifier` and `identifier_kind` filter only when BOTH are present. Either one on its own is accepted by the validator and then silently ignored, so a lookup by bare `identifier` returns the unfiltered page rather than an error or an empty list.","parameters":[{"schema":{"type":"string","enum":["supplier","customer","agent_peer"]},"required":false,"name":"kind","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"maxLength":256},"required":false,"name":"identifier","in":"query"},{"schema":{"type":"string","enum":["cnpj","cpf","email","phone","wallet_address","other"]},"required":false,"name":"identifier_kind","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"counterparties":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"`cp_` prefixed."},"kind":{"type":"string","enum":["supplier","customer","agent_peer"]},"name":{"type":"string"},"identifier":{"type":["string","null"],"description":"Null when the counterparty was registered without one."},"identifier_kind":{"type":["string","null"],"enum":["cnpj","cpf","email","phone","wallet_address","other"]},"metadata":{"type":"object","additionalProperties":{}},"created_at":{"type":"string","format":"date-time"},"last_seen_at":{"type":"string","format":"date-time","description":"Bumped by a re-registration of the same identifier and by every interaction written against this counterparty."}},"required":["id","kind","name","identifier","identifier_kind","metadata","created_at","last_seen_at"]}}},"required":["counterparties"]}}}},"400":{"description":"`invalid_query`, with the validator's issue list under `details.issues`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_query"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/commerce-memory/counterparties/{id}":{"get":{"deprecated":true,"externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979: canonical path and alias window"},"summary":"Read one counterparty (deprecated path)","description":"DEPRECATED alias of `GET /v1/counterparties/{id}` (ent#979), kept for two releases. The two paths are the same handler with the same required scope, so switching one for the other changes nothing about the request, the response or the credential. The full response is described here, so nothing about this operation is withheld until you switch paths.\n\nThe same row the list returns, by id. A counterparty that belongs to another org, or to a sibling project of the same org, is indistinguishable from one that does not exist: both are 404. Counterparty ids are unique per project and not globally, so the same id can name a different company in another project.","parameters":[{"schema":{"type":"string","description":"`cp_` prefixed."},"required":true,"description":"`cp_` prefixed.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`cp_` prefixed."},"kind":{"type":"string","enum":["supplier","customer","agent_peer"]},"name":{"type":"string"},"identifier":{"type":["string","null"],"description":"Null when the counterparty was registered without one."},"identifier_kind":{"type":["string","null"],"enum":["cnpj","cpf","email","phone","wallet_address","other"]},"metadata":{"type":"object","additionalProperties":{}},"created_at":{"type":"string","format":"date-time"},"last_seen_at":{"type":"string","format":"date-time","description":"Bumped by a re-registration of the same identifier and by every interaction written against this counterparty."}},"required":["id","kind","name","identifier","identifier_kind","metadata","created_at","last_seen_at"]}}}},"404":{"description":"`not_found`, including for a counterparty outside the caller's project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/commerce-memory/interactions":{"post":{"summary":"Record a commercial event against a counterparty","description":"This RECORDS an event; it moves no money and settles nothing. `amount_minor` is a figure being written into the commercial record, not an instruction to pay it.\n\nIDEMPOTENT ON `(source, source_ref)` WITHIN THE PROJECT, and only when `source_ref` is supplied. Re-posting a pair that already exists writes nothing and returns 200 with `{ idempotent: true, interaction }` carrying the row that was already there, so a retrying ingest pipeline is safe. Omit `source_ref` and there is no key: every call inserts, and a double post double counts.\n\nOn a real insert the counterparty's `last_seen_at` is bumped to now. On the idempotent path it is not.\n\n`amount_minor` and `currency` travel together: one without the other is 400. `occurred_at` defaults to the moment of the call, so backfilling history means sending it explicitly.\n\n404 `counterparty_not_found` when `counterparty_id` is not in the caller's org and project. A counterparty in a sibling project reads the same as one that does not exist.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"counterparty_id":{"type":"string","minLength":1,"maxLength":64},"kind":{"type":"string","enum":["quote","negotiation","order","invoice","payment","shipment","message","other"]},"amount_minor":{"type":"integer","description":"Minor units. Set it together with `currency` or omit both."},"currency":{"type":"string","minLength":2,"maxLength":8},"occurred_at":{"type":"string","format":"date-time","description":"Defaults to now."},"source":{"type":"string","enum":["agent_execute","webhook","manual","backfill"],"default":"agent_execute"},"source_ref":{"type":"string","minLength":1,"maxLength":256,"description":"The idempotency key, paired with `source`. Omit it and every post writes a new row."},"agent_id":{"type":"string","minLength":1,"maxLength":128},"metadata":{"type":"object","additionalProperties":{}}},"required":["counterparty_id","kind"]}}}},"responses":{"200":{"description":"Idempotent replay. `(source, source_ref)` already existed in this project; nothing was written and the stored row is returned.","content":{"application/json":{"schema":{"type":"object","properties":{"idempotent":{"type":"boolean","enum":[true]},"interaction":{"type":"object","properties":{"id":{"type":"string","description":"`ci_` prefixed."},"counterparty_id":{"type":"string"},"kind":{"type":"string","enum":["quote","negotiation","order","invoice","payment","shipment","message","other"]},"amount_minor":{"type":["integer","null"],"description":"Minor units. Stored as a 64 bit integer and rendered as a JSON number. Null unless `currency` is also set."},"currency":{"type":["string","null"]},"occurred_at":{"type":"string","format":"date-time"},"source":{"type":"string","enum":["agent_execute","webhook","manual","backfill"]},"source_ref":{"type":["string","null"],"description":"The caller's own key for the event, and the idempotency key when present."},"agent_id":{"type":["string","null"]},"metadata":{"type":"object","additionalProperties":{}}},"required":["id","counterparty_id","kind","amount_minor","currency","occurred_at","source","source_ref","agent_id","metadata"]}},"required":["idempotent","interaction"]}}}},"201":{"description":"Recorded.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`ci_` prefixed."},"counterparty_id":{"type":"string"},"kind":{"type":"string","enum":["quote","negotiation","order","invoice","payment","shipment","message","other"]},"amount_minor":{"type":["integer","null"],"description":"Minor units. Stored as a 64 bit integer and rendered as a JSON number. Null unless `currency` is also set."},"currency":{"type":["string","null"]},"occurred_at":{"type":"string","format":"date-time"},"source":{"type":"string","enum":["agent_execute","webhook","manual","backfill"]},"source_ref":{"type":["string","null"],"description":"The caller's own key for the event, and the idempotency key when present."},"agent_id":{"type":["string","null"]},"metadata":{"type":"object","additionalProperties":{}}},"required":["id","counterparty_id","kind","amount_minor","currency","occurred_at","source","source_ref","agent_id","metadata"]}}}},"400":{"description":"`invalid_body`, with the validator's issue list under `details.issues`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"`counterparty_not_found`: the id does not belong to this org and project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["counterparty_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"500":{"description":"`idempotent_lookup_failed`. The insert hit the idempotency key but the conflicting row could not be read back afterwards. Nothing was written; the call is safe to retry.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["idempotent_lookup_failed"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"get":{"summary":"List recorded interactions","description":"Newest `occurred_at` first, capped by `limit` (max 500). There is NO CURSOR, so a busy counterparty cannot be walked past the first page; narrow with `since` / `until` instead. `since` is inclusive and `until` is exclusive, which makes adjacent windows tile without double counting a row on the boundary.","parameters":[{"schema":{"type":"string","minLength":1,"maxLength":64},"required":false,"name":"counterparty_id","in":"query"},{"schema":{"type":"string","enum":["quote","negotiation","order","invoice","payment","shipment","message","other"]},"required":false,"name":"kind","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Inclusive lower bound on `occurred_at`."},"required":false,"description":"Inclusive lower bound on `occurred_at`.","name":"since","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Exclusive upper bound on `occurred_at`."},"required":false,"description":"Exclusive upper bound on `occurred_at`.","name":"until","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":500,"default":50},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"interactions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"`ci_` prefixed."},"counterparty_id":{"type":"string"},"kind":{"type":"string","enum":["quote","negotiation","order","invoice","payment","shipment","message","other"]},"amount_minor":{"type":["integer","null"],"description":"Minor units. Stored as a 64 bit integer and rendered as a JSON number. Null unless `currency` is also set."},"currency":{"type":["string","null"]},"occurred_at":{"type":"string","format":"date-time"},"source":{"type":"string","enum":["agent_execute","webhook","manual","backfill"]},"source_ref":{"type":["string","null"],"description":"The caller's own key for the event, and the idempotency key when present."},"agent_id":{"type":["string","null"]},"metadata":{"type":"object","additionalProperties":{}}},"required":["id","counterparty_id","kind","amount_minor","currency","occurred_at","source","source_ref","agent_id","metadata"]}}},"required":["interactions"]}}}},"400":{"description":"`invalid_query`, with the validator's issue list under `details.issues`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_query"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/commerce-memory/preferences":{"post":{"summary":"Set or update one preference on a counterparty","description":"An UPSERT keyed on `(counterparty_id, preference_key)` within the caller's org and project. Re-sending a key REPLACES `value`, `confidence` and `source` and bumps `updated_at`; `metadata` is merged over the stored object at the top level only. There is no way to append to a value from here, and no delete.\n\nThe key vocabulary is open on purpose (`payment_term_days`, `preferred_payment_method`, and whatever a later signal needs), which is why the only constraint on it is the shape: lowercase ASCII, digits and underscore.\n\nALWAYS SEND `value`, even though the schema below does not mark it required. The validator types it as `unknown`, which in Zod accepts a body that leaves the key out, so a body without it gets no 400 and reaches the insert. What that insert then stores is not specified by this document, and a preference whose value is a stored JSON `null` is not distinguishable afterwards from one an operator meant to set that way.\n\n`confidence` defaults to 1, which is the value meaning an operator asserted this rather than a job deriving it; `source` defaults to `explicit` for the same reason. A derived signal should send both.\n\nThe status is 201 on both paths, including the update.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"counterparty_id":{"type":"string","minLength":1,"maxLength":64},"preference_key":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[a-z0-9_]+$","description":"Lowercase ASCII, digits and underscore. The vocabulary is open: a new key needs no migration."},"value":{"description":"Any JSON value. Send it. The validator is `unknown`, which in Zod accepts a body that omits the key, but the column behind it is NOT NULL, so a body without `value` is not a supported call."},"confidence":{"type":"number","minimum":0,"maximum":1,"description":"Defaults to 1."},"source":{"type":"string","enum":["explicit","derived","inherited"],"description":"Defaults to `explicit`."},"metadata":{"type":"object","additionalProperties":{}}},"required":["counterparty_id","preference_key"]}}}},"responses":{"201":{"description":"Created, or updated in place.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`pref_` prefixed."},"counterparty_id":{"type":"string"},"preference_key":{"type":"string"},"value":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"array","items":{}},{"type":"object","additionalProperties":{}},{"type":"null"}],"description":"Any JSON value. Always present."},"confidence":{"type":"number","description":"0 to 1. 1 means an operator asserted it; below that it was derived."},"source":{"type":"string","enum":["explicit","derived","inherited"]},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"metadata":{"type":"object","additionalProperties":{}}},"required":["id","counterparty_id","preference_key","value","confidence","source","created_at","updated_at","metadata"]}}}},"400":{"description":"`invalid_body`, with the validator's issue list under `details.issues`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"`counterparty_not_found`: the id does not belong to this org and project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["counterparty_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"get":{"summary":"List preferences","description":"Most recently updated first, capped by `limit` (max 500, default 100). No cursor. Filter by `counterparty_id` for a single counterparty's full preference set, or by `source` to separate what an operator asserted from what a job derived.","parameters":[{"schema":{"type":"string","minLength":1,"maxLength":64},"required":false,"name":"counterparty_id","in":"query"},{"schema":{"type":"string","enum":["explicit","derived","inherited"]},"required":false,"name":"source","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":500,"default":100},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"preferences":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"`pref_` prefixed."},"counterparty_id":{"type":"string"},"preference_key":{"type":"string"},"value":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"array","items":{}},{"type":"object","additionalProperties":{}},{"type":"null"}],"description":"Any JSON value. Always present."},"confidence":{"type":"number","description":"0 to 1. 1 means an operator asserted it; below that it was derived."},"source":{"type":"string","enum":["explicit","derived","inherited"]},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"metadata":{"type":"object","additionalProperties":{}}},"required":["id","counterparty_id","preference_key","value","confidence","source","created_at","updated_at","metadata"]}}},"required":["preferences"]}}}},"400":{"description":"`invalid_query`, with the validator's issue list under `details.issues`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_query"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/commerce-memory/negotiations":{"post":{"summary":"Record a price negotiation and its outcome","description":"One row per negotiation thread. Unlike the other two writes on this resource this one has NO IDEMPOTENCY KEY: posting the same negotiation twice produces two rows, and both then feed the negotiation-stats rollup.\n\n`outcome` defaults to `pending`. `closed_at` is stored only when the outcome is terminal (`agreed`, `declined` or `expired`) and is stamped to now when the outcome is terminal and `closed_at` was omitted; while the outcome is `pending` a supplied `closed_at` is dropped without an error. The two fields cannot disagree in a stored row.\n\n`outcome: agreed` requires `agreed_price_minor`, and a body missing it is 400 rather than a row with no agreed price. `currency` is required on every negotiation; both price fields are minor units of it.\n\n`item_ref` is optional and is the key the negotiation-stats rollup groups on, so a negotiation posted without one is aggregated into that endpoint's null group instead of against the sku.\n\n404 `counterparty_not_found` when `counterparty_id` is not in the caller's org and project.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"counterparty_id":{"type":"string","minLength":1,"maxLength":64},"item":{"type":"string","minLength":1,"maxLength":512,"description":"Free text description of what was negotiated."},"item_ref":{"type":"string","minLength":1,"maxLength":128,"description":"Structured pointer (sku, contract id, supplier product code). It is the grouping key of the negotiation-stats rollup."},"initial_price_minor":{"type":"integer","description":"Minor units of `currency`."},"agreed_price_minor":{"type":"integer","description":"Minor units of `currency`. Required when `outcome` is `agreed`."},"currency":{"type":"string","minLength":2,"maxLength":8},"rounds":{"type":"integer","minimum":1,"description":"Defaults to 1."},"outcome":{"type":"string","enum":["agreed","declined","pending","expired"],"description":"Defaults to `pending`."},"agent_id":{"type":"string","minLength":1,"maxLength":128},"started_at":{"type":"string","format":"date-time","description":"Defaults to now."},"closed_at":{"type":"string","format":"date-time","description":"Read only when `outcome` is terminal. Ignored while the outcome is `pending`."},"metadata":{"type":"object","additionalProperties":{}}},"required":["counterparty_id","item","currency"]}}}},"responses":{"201":{"description":"Recorded.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`neg_` prefixed."},"counterparty_id":{"type":"string"},"item":{"type":"string"},"item_ref":{"type":["string","null"]},"initial_price_minor":{"type":["integer","null"],"description":"Minor units of `currency`."},"agreed_price_minor":{"type":["integer","null"],"description":"Minor units of `currency`."},"currency":{"type":"string"},"rounds":{"type":"integer"},"outcome":{"type":"string","enum":["agreed","declined","pending","expired"]},"agent_id":{"type":["string","null"]},"started_at":{"type":"string","format":"date-time"},"closed_at":{"type":["string","null"],"format":"date-time","description":"Null exactly when `outcome` is `pending`."},"metadata":{"type":"object","additionalProperties":{}}},"required":["id","counterparty_id","item","item_ref","initial_price_minor","agreed_price_minor","currency","rounds","outcome","agent_id","started_at","closed_at","metadata"]}}}},"400":{"description":"`invalid_body`, with the validator's issue list under `details.issues`. Includes the `outcome: agreed` without `agreed_price_minor` case.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"`counterparty_not_found`: the id does not belong to this org and project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["counterparty_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"get":{"summary":"List negotiations","description":"Newest `started_at` first, capped by `limit` (max 500). No cursor. `item_ref` matches exactly, so it reads one sku's negotiation history across every counterparty in the project.","parameters":[{"schema":{"type":"string","minLength":1,"maxLength":64},"required":false,"name":"counterparty_id","in":"query"},{"schema":{"type":"string","enum":["agreed","declined","pending","expired"]},"required":false,"name":"outcome","in":"query"},{"schema":{"type":"string","minLength":1,"maxLength":128},"required":false,"name":"item_ref","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":500,"default":50},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"negotiations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"`neg_` prefixed."},"counterparty_id":{"type":"string"},"item":{"type":"string"},"item_ref":{"type":["string","null"]},"initial_price_minor":{"type":["integer","null"],"description":"Minor units of `currency`."},"agreed_price_minor":{"type":["integer","null"],"description":"Minor units of `currency`."},"currency":{"type":"string"},"rounds":{"type":"integer"},"outcome":{"type":"string","enum":["agreed","declined","pending","expired"]},"agent_id":{"type":["string","null"]},"started_at":{"type":"string","format":"date-time"},"closed_at":{"type":["string","null"],"format":"date-time","description":"Null exactly when `outcome` is `pending`."},"metadata":{"type":"object","additionalProperties":{}}},"required":["id","counterparty_id","item","item_ref","initial_price_minor","agreed_price_minor","currency","rounds","outcome","agent_id","started_at","closed_at","metadata"]}}},"required":["negotiations"]}}}},"400":{"description":"`invalid_query`, with the validator's issue list under `details.issues`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_query"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/commerce-memory/insights/counterparty-summary":{"get":{"summary":"Per counterparty rollup over a window","description":"One row per counterparty in the project: how many interactions it had in the window, how many of those were payments, what they summed to, and which currencies appeared.\n\n`total_payment_minor` ADDS ACROSS CURRENCIES. It sums every PAYMENT interaction in the window whatever its currency, so a counterparty billed in both BRL and USD gets a single number that is an amount in neither.\n\n`currencies` DOES NOT TELL YOU WHETHER THAT HAPPENED, and it is the field most likely to be read as if it did. It aggregates the distinct currencies of the window's interactions of EVERY kind, while the total is built from payments alone: an invoice priced in USD next to a payment in BRL yields two entries over a total that is pure BRL. The set is an upper bound on the currencies inside the total, so at most one entry does bound the total to a single currency, and two or more prove nothing either way. To attribute a total to a currency, list the window's interactions with `kind=payment` and add them up per currency.\n\nThe window filters the interactions, not the counterparties. A counterparty with nothing in the window still appears, with zero counts and a zero total, which is what makes this usable for spotting dormant relationships. Omitting both bounds sums the whole history.\n\nOrdered by `total_payment_minor` descending, then by `last_seen_at` descending, capped by `limit` (max 500, default 100). No cursor.","parameters":[{"schema":{"type":"string","enum":["supplier","customer","agent_peer"]},"required":false,"name":"kind","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Inclusive lower bound on `occurred_at`."},"required":false,"description":"Inclusive lower bound on `occurred_at`.","name":"since","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Exclusive upper bound on `occurred_at`."},"required":false,"description":"Exclusive upper bound on `occurred_at`.","name":"until","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":500,"default":100},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"window":{"type":"object","properties":{"since":{"type":["string","null"],"format":"date-time"},"until":{"type":["string","null"],"format":"date-time"}},"required":["since","until"],"description":"The `since` and `until` of the request, echoed back, null when not supplied."},"rows":{"type":"array","items":{"type":"object","properties":{"counterparty_id":{"type":"string"},"name":{"type":"string"},"kind":{"type":"string","enum":["supplier","customer","agent_peer"]},"identifier":{"type":["string","null"]},"identifier_kind":{"type":["string","null"],"enum":["cnpj","cpf","email","phone","wallet_address","other"]},"last_seen_at":{"type":"string","format":"date-time"},"interaction_count":{"type":"integer","description":"Interactions of every kind inside the window."},"payment_count":{"type":"integer","description":"Of those, the ones with `kind: payment`."},"total_payment_minor":{"type":"integer","description":"Sum of `amount_minor` over the window's PAYMENT interactions only, ACROSS CURRENCIES. Read `currencies` before reading this as an amount."},"currencies":{"type":"array","items":{"type":"string"},"description":"Distinct non null currencies over the window's interactions of EVERY kind, not only the payments the total is built from. It is therefore an UPPER BOUND on the currencies inside `total_payment_minor` and never a smaller set: an invoice priced in USD puts USD here while the total stays pure BRL. One entry (or none) does bound the total to a single currency; two or more do not prove it mixes."}},"required":["counterparty_id","name","kind","identifier","identifier_kind","last_seen_at","interaction_count","payment_count","total_payment_minor","currencies"]}}},"required":["window","rows"]}}}},"400":{"description":"`invalid_query`, with the validator's issue list under `details.issues`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_query"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/commerce-memory/insights/negotiation-stats":{"get":{"summary":"Negotiation outcomes and average discount, grouped by item","description":"One row per `(item_ref, outcome)` pair among the negotiations whose `started_at` falls in the window. Pass `item_ref` to narrow to a single sku; leave it out and every group in the project comes back.\n\n`avg_discount_pct` IS A FRACTION, NOT A PERCENTAGE. It is the mean of `(initial_price_minor - agreed_price_minor) / initial_price_minor`, so 0.075 means the agreed price landed 7.5 percent below the opening ask. A negative value means the close was ABOVE the opening ask. It is null on every group whose outcome is not `agreed`, and null on an `agreed` group where no negotiation carries both a positive `initial_price_minor` and an `agreed_price_minor`; `discount_sample_n` says how many rows actually fed it, and it is routinely smaller than `n`.\n\nTHIS ENDPOINT HAS NO `limit`. The response carries every group in the window, so a wide window on a project with many distinct `item_ref` values returns a large body. Ordered by `item_ref` with the null group last, then by `outcome`.","parameters":[{"schema":{"type":"string","minLength":1,"maxLength":128},"required":false,"name":"item_ref","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Inclusive lower bound on `started_at`."},"required":false,"description":"Inclusive lower bound on `started_at`.","name":"since","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Exclusive upper bound on `started_at`."},"required":false,"description":"Exclusive upper bound on `started_at`.","name":"until","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"window":{"type":"object","properties":{"since":{"type":["string","null"],"format":"date-time"},"until":{"type":["string","null"],"format":"date-time"}},"required":["since","until"],"description":"The `since` and `until` of the request, echoed back, null when not supplied."},"item_ref":{"type":["string","null"],"description":"The `item_ref` filter of the request, echoed back."},"rows":{"type":"array","items":{"type":"object","properties":{"item_ref":{"type":["string","null"],"description":"Null groups the negotiations that carry no `item_ref`."},"outcome":{"type":"string","enum":["agreed","declined","pending","expired"]},"n":{"type":"integer","description":"Negotiations in this (item_ref, outcome) group."},"avg_rounds":{"type":["number","null"]},"currencies":{"type":"array","items":{"type":"string"},"description":"Distinct currencies inside the group."},"avg_discount_pct":{"type":["number","null"],"description":"A FRACTION of the initial price, not a percentage: 0.075 is 7.5 percent below the opening ask. Null on every group whose `outcome` is not `agreed`, and null when no negotiation in the group carries a positive `initial_price_minor` together with an `agreed_price_minor`."},"discount_sample_n":{"type":"integer","description":"How many negotiations fed `avg_discount_pct`. Can be smaller than `n`, and is 0 exactly when the average is null."}},"required":["item_ref","outcome","n","avg_rounds","currencies","avg_discount_pct","discount_sample_n"]}}},"required":["window","item_ref","rows"]}}}},"400":{"description":"`invalid_query`, with the validator's issue list under `details.issues`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_query"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/commerce-memory/insights/top-counterparties":{"get":{"summary":"Counterparties ranked by payment volume in a window","description":"The leaderboard cut of the summary above, for a dashboard widget that does not want the long tail: ordered by `total_payment_minor` descending and capped at `limit` (max 50, default 10).\n\nIT DROPS ROWS THE SUMMARY KEEPS. A counterparty with no payment interaction inside the window is absent here, where `counterparty-summary` lists it with zeros. Absence from this response is not absence from the project.\n\n`total_payment_minor` adds across currencies, exactly as in the summary, and nothing in this response tells you whether a given total did. `dominant_currency` is not that check and neither is the summary's `currencies`: this one is the most frequent currency across ALL of the counterparty's payment interactions and ignores `since` and `until`, so it can name a currency the window never saw and it stays populated for a counterparty that used two; that one spans every interaction kind and is only an upper bound. A ranking you need to trust as money is one you build from the window's `kind=payment` interactions, per currency.","parameters":[{"schema":{"type":"string","enum":["supplier","customer","agent_peer"]},"required":false,"name":"kind","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Inclusive lower bound on `occurred_at`."},"required":false,"description":"Inclusive lower bound on `occurred_at`.","name":"since","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Exclusive upper bound on `occurred_at`."},"required":false,"description":"Exclusive upper bound on `occurred_at`.","name":"until","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":50,"default":10},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"window":{"type":"object","properties":{"since":{"type":["string","null"],"format":"date-time"},"until":{"type":["string","null"],"format":"date-time"}},"required":["since","until"],"description":"The `since` and `until` of the request, echoed back, null when not supplied."},"kind":{"type":["string","null"],"enum":["supplier","customer","agent_peer"],"description":"The `kind` filter of the request, echoed back."},"rows":{"type":"array","items":{"type":"object","properties":{"counterparty_id":{"type":"string"},"name":{"type":"string"},"kind":{"type":"string","enum":["supplier","customer","agent_peer"]},"payment_count":{"type":"integer"},"total_payment_minor":{"type":"integer","description":"Sum of `amount_minor` over the window's payment interactions, ACROSS CURRENCIES."},"dominant_currency":{"type":["string","null"],"description":"The most frequent currency over ALL of this counterparty's payment interactions, ignoring `since` and `until`, so it can name a currency the window itself never saw."}},"required":["counterparty_id","name","kind","payment_count","total_payment_minor","dominant_currency"]}}},"required":["window","kind","rows"]}}}},"400":{"description":"`invalid_query`, with the validator's issue list under `details.issues`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_query"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/connections":{"post":{"summary":"Register a provider key, or rotate the one already there","description":"Encrypts `secret` and writes the connection in one transaction: either the ciphertext and the row both land, or neither does.\n\nTHIS ENDPOINT IS NOT AN INSERT. A `status=connected` connection for the same (org, project, server) makes the call a ROTATION: the vault entry is overwritten, `display_name` and `connection_metadata` are refreshed only when the body carries them, `expires_at` is cleared, and the EXISTING row comes back with 200. A first connection comes back with 201. `user_id` is not part of that identity, so a second team member reconnecting the same server rotates the shared credential rather than adding a second one.\n\n`secret` takes two shapes and the server picks which one is legal from the catalog's `auth_type`. A plain string is for single-key providers. An object is for the multi-ref kinds (path_secret, cert, hmac_signed, jwt_ecdsa, two_header) and its keys must match the provider's declared refs EXACTLY: a missing or extra key is 400 `path_secret_keys_mismatch` with both lists, never a silent drop, because dropping one leaves the provider unusable at call time with no trace of why.\n\nOAuth providers are refused here with 400 `not_api_key_server`; they go through `POST /v1/connections/start`. An unknown catalog id is 404 `server_unknown`. A provider whose catalog row declares no refs is 500 `endpoint_missing_refs`, which is a seed defect on our side and not a bad request.\n\nService-auth callers must send `x-codespar-user` and hold admin or above. Bearer callers are gated by possession of the key alone. The secret is never echoed back by this or any other operation.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"server_id":{"type":"string","minLength":1,"maxLength":64},"secret":{"anyOf":[{"type":"string","minLength":1,"maxLength":4096},{"type":"object","additionalProperties":{"type":"string","minLength":1,"maxLength":4096}}]},"display_name":{"type":"string","minLength":1,"maxLength":128},"user_id":{"type":"string","minLength":1,"maxLength":128},"connection_metadata":{"type":"object","additionalProperties":{}}},"required":["server_id","secret"]}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`ca_`-prefixed. Globally unique, visible only inside the owning org and project."},"user_id":{"type":"string"},"server_id":{"type":"string","description":"Catalog id of the provider this connection authenticates."},"auth_type":{"type":"string","description":"Mirrors the catalog's `auth_type` at connect time: api_key, path_secret, cert, hmac_signed, jwt_ecdsa, two_header, oauth, cdp or none. Left open because the column is plain text."},"status":{"type":"string","enum":["pending","connected","revoked","expired"]},"display_name":{"type":["string","null"]},"metadata":{"type":["object","null"],"additionalProperties":{},"description":"Provider metadata the OAuth callback wrote (scope, refresh ref) or the provisioning projection wrote (account_id). Always present; null for a key registered through POST /v1/connections, which never sets it."},"connection_metadata":{"type":"object","additionalProperties":{},"description":"Operator-supplied merchant config the router merges into upstream calls. `{}` when nothing was set."},"cert_metadata":{"type":"object","additionalProperties":{},"description":"Issuer, subject, validity window and SHA-256 fingerprint parsed from the uploaded PEM at connect time. `{}` for every non-cert connection."},"created_at":{"type":"string","format":"date-time"},"connected_at":{"type":["string","null"],"format":"date-time"},"revoked_at":{"type":["string","null"],"format":"date-time"},"expires_at":{"type":["string","null"],"format":"date-time"}},"required":["id","user_id","server_id","auth_type","status","display_name","metadata","connection_metadata","cert_metadata","created_at","connected_at","revoked_at","expires_at"]}}}},"201":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`ca_`-prefixed. Globally unique, visible only inside the owning org and project."},"user_id":{"type":"string"},"server_id":{"type":"string","description":"Catalog id of the provider this connection authenticates."},"auth_type":{"type":"string","description":"Mirrors the catalog's `auth_type` at connect time: api_key, path_secret, cert, hmac_signed, jwt_ecdsa, two_header, oauth, cdp or none. Left open because the column is plain text."},"status":{"type":"string","enum":["pending","connected","revoked","expired"]},"display_name":{"type":["string","null"]},"metadata":{"type":["object","null"],"additionalProperties":{},"description":"Provider metadata the OAuth callback wrote (scope, refresh ref) or the provisioning projection wrote (account_id). Always present; null for a key registered through POST /v1/connections, which never sets it."},"connection_metadata":{"type":"object","additionalProperties":{},"description":"Operator-supplied merchant config the router merges into upstream calls. `{}` when nothing was set."},"cert_metadata":{"type":"object","additionalProperties":{},"description":"Issuer, subject, validity window and SHA-256 fingerprint parsed from the uploaded PEM at connect time. `{}` for every non-cert connection."},"created_at":{"type":"string","format":"date-time"},"connected_at":{"type":["string","null"],"format":"date-time"},"revoked_at":{"type":["string","null"],"format":"date-time"},"expires_at":{"type":["string","null"],"format":"date-time"}},"required":["id","user_id","server_id","auth_type","status","display_name","metadata","connection_metadata","cert_metadata","created_at","connected_at","revoked_at","expires_at"]}}}},"400":{"description":"The body did not parse, the owner could not be resolved, the server is not key-authenticated, or the secret's shape or keys do not match what the provider declares.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body","user_id_required","not_api_key_server","invalid_secret_shape","path_secret_keys_mismatch"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"403":{"description":"Service auth without `x-codespar-user`, or with a role below admin.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["insufficient_role"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"No such server in the catalog.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["server_unknown"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"500":{"description":"The provider's catalog row declares no path-secret refs, so there is nowhere to put the values. A seeding defect, not a bad request.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["endpoint_missing_refs"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"503":{"description":"The vault or the connection write failed. Nothing was persisted.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["vault_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"get":{"summary":"List the connections in this project","description":"Active and historical both: a revoked or expired row stays for audit and is returned unless `status` filters it out. Newest first, capped by `limit` (default 50, maximum 100). The route does not paginate beyond that cap.\n\nScope is the calling credential's org AND project. A connection belonging to a sibling project of the same org is absent here and 404s on read, so this list and the point read agree.","parameters":[{"schema":{"type":"integer","minimum":1,"maximum":100,"default":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string"},"required":false,"name":"user_id","in":"query"},{"schema":{"type":"string"},"required":false,"name":"server_id","in":"query"},{"schema":{"type":"string","enum":["pending","connected","revoked","expired"]},"required":false,"name":"status","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"connections":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"`ca_`-prefixed. Globally unique, visible only inside the owning org and project."},"user_id":{"type":"string"},"server_id":{"type":"string","description":"Catalog id of the provider this connection authenticates."},"auth_type":{"type":"string","description":"Mirrors the catalog's `auth_type` at connect time: api_key, path_secret, cert, hmac_signed, jwt_ecdsa, two_header, oauth, cdp or none. Left open because the column is plain text."},"status":{"type":"string","enum":["pending","connected","revoked","expired"]},"display_name":{"type":["string","null"]},"metadata":{"type":["object","null"],"additionalProperties":{},"description":"Provider metadata the OAuth callback wrote (scope, refresh ref) or the provisioning projection wrote (account_id). Always present; null for a key registered through POST /v1/connections, which never sets it."},"connection_metadata":{"type":"object","additionalProperties":{},"description":"Operator-supplied merchant config the router merges into upstream calls. `{}` when nothing was set."},"cert_metadata":{"type":"object","additionalProperties":{},"description":"Issuer, subject, validity window and SHA-256 fingerprint parsed from the uploaded PEM at connect time. `{}` for every non-cert connection."},"created_at":{"type":"string","format":"date-time"},"connected_at":{"type":["string","null"],"format":"date-time"},"revoked_at":{"type":["string","null"],"format":"date-time"},"expires_at":{"type":["string","null"],"format":"date-time"}},"required":["id","user_id","server_id","auth_type","status","display_name","metadata","connection_metadata","cert_metadata","created_at","connected_at","revoked_at","expires_at"]}}},"required":["connections"]}}}},"400":{"description":"Bad Request — the body or query did not match the schema.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body","invalid_query"]},"issues":{"type":"array","items":{},"description":"Zod issues, when the route reports them"}},"required":["error"]}}}}}}},"/v1/connections/{id}":{"get":{"summary":"Read one connection","description":"404 covers three different situations and deliberately does not distinguish them: no such id, an id owned by another org, and an id owned by a sibling project of the same org. Answering 403 for the last two would confirm the id exists.","parameters":[{"schema":{"type":"string","description":"`ca_`-prefixed connection id"},"required":true,"description":"`ca_`-prefixed connection id","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`ca_`-prefixed. Globally unique, visible only inside the owning org and project."},"user_id":{"type":"string"},"server_id":{"type":"string","description":"Catalog id of the provider this connection authenticates."},"auth_type":{"type":"string","description":"Mirrors the catalog's `auth_type` at connect time: api_key, path_secret, cert, hmac_signed, jwt_ecdsa, two_header, oauth, cdp or none. Left open because the column is plain text."},"status":{"type":"string","enum":["pending","connected","revoked","expired"]},"display_name":{"type":["string","null"]},"metadata":{"type":["object","null"],"additionalProperties":{},"description":"Provider metadata the OAuth callback wrote (scope, refresh ref) or the provisioning projection wrote (account_id). Always present; null for a key registered through POST /v1/connections, which never sets it."},"connection_metadata":{"type":"object","additionalProperties":{},"description":"Operator-supplied merchant config the router merges into upstream calls. `{}` when nothing was set."},"cert_metadata":{"type":"object","additionalProperties":{},"description":"Issuer, subject, validity window and SHA-256 fingerprint parsed from the uploaded PEM at connect time. `{}` for every non-cert connection."},"created_at":{"type":"string","format":"date-time"},"connected_at":{"type":["string","null"],"format":"date-time"},"revoked_at":{"type":["string","null"],"format":"date-time"},"expires_at":{"type":["string","null"],"format":"date-time"}},"required":["id","user_id","server_id","auth_type","status","display_name","metadata","connection_metadata","cert_metadata","created_at","connected_at","revoked_at","expires_at"]}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}},"delete":{"summary":"Delete a revoked connection for good","description":"Hard delete, allowed only once `status` is `revoked` or `expired`. An active connection is refused with 409 rather than deleted, because dropping the row while the credential still works would lose the audit trail and leave the vault entry with nothing pointing at it.\n\nThe purge runs inside the same transaction, before the row goes. That matters for an `expired` connection, which reaches here without ever having been revoked and so has never been purged; for an already-revoked one the purge is a no-op.\n\nThe 409 body is a bare `{ error, message }` pair, not the `error.code` envelope the newer routes use.","parameters":[{"schema":{"type":"string","description":"`ca_`-prefixed connection id"},"required":true,"description":"`ca_`-prefixed connection id","name":"id","in":"path"}],"responses":{"204":{"description":"No Content"},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}},"409":{"description":"The connection is still active. Revoke it first.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["cannot_delete_active"]},"message":{"type":"string"}},"required":["error","message"]}}}}}}},"/v1/connections/{id}/revoke":{"post":{"summary":"Revoke a connection and destroy its stored credential","description":"Flips the row to `revoked` AND deletes the vault rows behind it, in one transaction. The row survives because audit needs `connected_at` after the credential is gone; the ciphertext does not. A revoke that could not purge fails with 503 and leaves the connection `connected`, which is the honest outcome: reporting revoked while the secret stays decryptable is the failure this route exists to prevent.\n\nA vault entry is dropped only when no other still-connected row in the same org and project would resolve it. Sibling providers can share one bundle, so a shared key survives until the last connection using it is revoked.\n\nTWO DIFFERENT 200 BODIES. A revoke that did work returns the full connection row. A connection already `revoked` returns the short `{ id, status, already }` form and touches nothing, so a retry is safe. Branch on the presence of `already`, not on the status code.","parameters":[{"schema":{"type":"string","description":"`ca_`-prefixed connection id"},"required":true,"description":"`ca_`-prefixed connection id","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"id":{"type":"string","description":"`ca_`-prefixed. Globally unique, visible only inside the owning org and project."},"user_id":{"type":"string"},"server_id":{"type":"string","description":"Catalog id of the provider this connection authenticates."},"auth_type":{"type":"string","description":"Mirrors the catalog's `auth_type` at connect time: api_key, path_secret, cert, hmac_signed, jwt_ecdsa, two_header, oauth, cdp or none. Left open because the column is plain text."},"status":{"type":"string","enum":["pending","connected","revoked","expired"]},"display_name":{"type":["string","null"]},"metadata":{"type":["object","null"],"additionalProperties":{},"description":"Provider metadata the OAuth callback wrote (scope, refresh ref) or the provisioning projection wrote (account_id). Always present; null for a key registered through POST /v1/connections, which never sets it."},"connection_metadata":{"type":"object","additionalProperties":{},"description":"Operator-supplied merchant config the router merges into upstream calls. `{}` when nothing was set."},"cert_metadata":{"type":"object","additionalProperties":{},"description":"Issuer, subject, validity window and SHA-256 fingerprint parsed from the uploaded PEM at connect time. `{}` for every non-cert connection."},"created_at":{"type":"string","format":"date-time"},"connected_at":{"type":["string","null"],"format":"date-time"},"revoked_at":{"type":["string","null"],"format":"date-time"},"expires_at":{"type":["string","null"],"format":"date-time"}},"required":["id","user_id","server_id","auth_type","status","display_name","metadata","connection_metadata","cert_metadata","created_at","connected_at","revoked_at","expires_at"]},{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string","enum":["revoked"]},"already":{"type":"boolean","enum":[true],"description":"The connection was already revoked. Nothing changed."}},"required":["id","status","already"]}]}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}},"503":{"description":"The atomic flip and purge failed. Nothing changed; the connection is still usable. Retry.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["revoke_failed"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/connections/{id}/webhook-secret":{"put":{"summary":"Seed or rotate the secret that verifies this provider's inbound webhooks","description":"The value a provider signs its callbacks with (a Stripe signing secret, an Asaas access token, a Zoop Basic blob). The vault ref is derived from the provider internally, so a caller never has to know the naming convention.\n\nWrite-only: the plaintext is accepted once and no operation returns it. `updated` is the only thing the response says about the previous state, and it says exactly one thing: true when a secret was already stored under that ref, false when this call seeded the first one.\n\nA provider with no inbound webhook adapter is refused with 400 `provider_has_no_inbound_webhooks`, since a secret nothing will ever verify is worse than no secret at all. This write is not transactional with anything else: a 503 means the vault write failed and the old secret, if any, is still in place.","parameters":[{"schema":{"type":"string","description":"`ca_`-prefixed connection id"},"required":true,"description":"`ca_`-prefixed connection id","name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"secret":{"type":"string","minLength":1,"maxLength":1024}},"required":["secret"]}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"connection_id":{"type":"string"},"server_id":{"type":"string"},"updated":{"type":"boolean","description":"True when a secret already existed under this ref and was replaced; false when this call seeded the first one."}},"required":["connection_id","server_id","updated"]}}}},"400":{"description":"The body did not parse, or the provider has no inbound webhook adapter in this API.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body","provider_has_no_inbound_webhooks"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}},"503":{"description":"The vault write failed. Any previously stored secret is untouched.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["vault_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/connections/start":{"post":{"summary":"Begin the Connect Link OAuth flow for a provider","description":"The entry point for providers that cannot be connected by posting a key. It mints a single-use state token, stores the caller's org, project and environment against it, and returns the provider's authorize URL with that token already embedded.\n\nSend the user to `authorize_url`. The provider redirects to our callback, which exchanges the code, vaults the tokens under the project recorded at start, revokes any existing connection for the same (project, user, server) and writes the new one. The callback is not part of this document: it is unauthenticated by necessity, since the provider carries no CodeSpar credential.\n\n`link_token` is the same value as the `state` parameter inside `authorize_url`; it is single use and expires at `expires_at`, ten minutes out. `redirect_uri` must be https and is where the callback sends the browser back, with `status=connected&connection_id=…`, `status=denied&error=…` or `status=error&error=…` appended.\n\n`POST /v1/connect/start` is the same handler on a second path. A provider with no OAuth configuration is 404; a missing platform client credential is 500, which is our seeding defect and not the caller's error. Both bodies are bare `{ error, … }`, not the `error.code` envelope.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"server_id":{"type":"string","minLength":1,"maxLength":64},"user_id":{"type":"string","minLength":1,"maxLength":128},"redirect_uri":{"type":"string","format":"uri"},"scopes":{"type":"string","maxLength":2048}},"required":["server_id","user_id","redirect_uri"]}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"link_token":{"type":"string","description":"Single-use state token, also embedded in `authorize_url`."},"authorize_url":{"type":"string","format":"uri","description":"Send the user here. Carries client_id, our callback as redirect_uri, the state token, response_type=code and the resolved scopes."},"expires_at":{"type":"string","format":"date-time","description":"Ten minutes after the call. A callback arriving later is refused."}},"required":["link_token","authorize_url","expires_at"]}}}},"400":{"description":"Bad Request — the body or query did not match the schema.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body","invalid_query"]},"issues":{"type":"array","items":{},"description":"Zod issues, when the route reports them"}},"required":["error"]}}}},"404":{"description":"The provider has no OAuth configuration in the catalog.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["server_oauth_not_configured"]},"server_id":{"type":"string"}},"required":["error","server_id"]}}}},"500":{"description":"The platform's OAuth client credential is not seeded for this provider. A configuration defect on our side.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["oauth_client_not_configured"]},"message":{"type":"string"}},"required":["error","message"]}}}}}}},"/v1/connections/engine/{run_id}/status":{"get":{"summary":"Poll a browser-driven signup run","description":"Read-only. The run resolves only inside the calling credential's org and project, so a run id from another tenant is 404 rather than 403.\n\nPoll this while `status` is `running`. When it becomes `needs_verification` the customer has to clear the provider's email gate, and `verification.mechanism` says how: `code` means a one-time code to relay, `link` means a link to click in their own inbox. Branch on the mechanism, never on the provider name. A paused run that has expired answers 410 `run_stale` and cannot be resumed.\n\nOn `provisioned`, `connection_id` names the connection the run produced, which then behaves like any other row on this resource. On `failed`, `code` is drawn from a closed first-party vocabulary and `failed_step_id` names the step, when the run recorded one; a failed run that recorded no reason reports its status alone. The bounded failure detail, the page URL and the screenshot references are deliberately not selected by this query, so they cannot leak from it.\n\nThe 404 body here is `{ error: { code, message } }` with no `request_id`, unlike the 410 beside it.","parameters":[{"schema":{"type":"string"},"required":true,"name":"run_id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["running","needs_verification","provisioned","failed"]},"prompt":{"type":"string","description":"Present while the run waits on the customer. Fallback copy when the modal has none of its own."},"verification":{"type":"object","properties":{"mechanism":{"type":"string","enum":["code","link"],"description":"`code` means the provider mailed a one-time code to type; `link` means it mailed an activation link to click."},"resend":{"type":"boolean"}},"required":["mechanism"],"description":"Resolved from the provider's public descriptor. Present only while `status` is `needs_verification`."},"probe_result":{"description":"Result of the post-provision reachability probe, when one ran."},"connection_id":{"type":"string","description":"The `ca_` id of the connection the run produced. Present once the run reaches `provisioned`."},"code":{"type":"string","enum":["flow_failed","capacity","no_credential_captured","probe_failed","drive_deadline_exceeded","provider_account_not_registered","drive_stalled","resume_inputs_unavailable"],"description":"Why the run failed. A stored value outside this vocabulary is reported as absent rather than passed through, so a failed run can render as its status alone."},"failed_step_id":{"type":"string"}},"required":["status"]}}}},"404":{"description":"No run with that id inside the caller's org and project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"}},"required":["code","message"]}},"required":["error"]}}}},"410":{"description":"The paused run expired before it was resumed.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["run_stale"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/connections/subaccount/{id}/revoke":{"post":{"summary":"Revoke a provisioned subaccount and try to delete it upstream","description":"The revoke for accounts this platform minted on the customer's behalf. It is not `POST /v1/connections/{id}/revoke`: the generic purge targets the ref keyed on the server id, which is the wrong ref for a per-account subaccount credential, and it touches neither the provisioning record nor the account at the provider.\n\nLocal teardown first, atomically: the connection projection flips, the provisioning record flips, and the correct per-account vault ref is purged. A failure in that phase changes nothing and answers 503, so a retry is safe. Only then is a delete attempted at the provider.\n\nREAD `upstream` BEFORE TREATING THIS AS FINISHED. `deleted` means the account is gone at the provider. `not_applicable` means the lane has no delete to make. `delete_failed` and `orphaned` mean the credential is destroyed on our side but an account may still exist upstream, and closing it is a manual step. All four come back with 200, because the local teardown did succeed.\n\n`{id}` is a connection id for the customer path, matched only inside the caller's own org and project. It is a provisioning record id for the operator-internal path, which additionally requires service auth. A customer credential can never reach an operator-internal record: it misses the connection lookup and gets 404, never a 403 that would confirm the id exists. A repeated revoke returns `already: true` and changes nothing.","parameters":[{"schema":{"type":"string","description":"A `ca_` connection id, or a `pr_` provisioning record id on the operator-internal path."},"required":true,"description":"A `ca_` connection id, or a `pr_` provisioning record id on the operator-internal path.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["revoked"]},"record_id":{"type":"string"},"connection_id":{"type":"string","description":"The connection projection's id, or the provisioning record's id when there is no projection."},"upstream":{"type":"string","enum":["deleted","delete_failed","not_applicable","orphaned"],"description":"What happened at the provider. `delete_failed` and `orphaned` mean an account may still exist there; the local credential is gone either way."},"already":{"type":"boolean","enum":[true],"description":"Present when the subaccount was already revoked. Nothing changed."}},"required":["status","record_id","connection_id","upstream"]}}}},"403":{"description":"This is a write, so it carries a role floor: a service-auth caller must send `x-codespar-user` and hold admin or above.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["insufficient_role"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"No connection or operator-internal record with that id is reachable by this caller.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"}},"required":["code","message"]}},"required":["error"]}}}},"500":{"description":"The revoke threw unexpectedly.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["revoke_failed"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"503":{"description":"The internal phase failed and left nothing changed. Retry.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["revoke_failed"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/connections/subaccount/{id}/status":{"get":{"summary":"Read whether a provisioned subaccount can transact yet","description":"Read-only, with one live call to the provider. It writes nothing and changes no state.\n\n`charges_enabled` is what the provider says right now, not a cached flag, which is why a provider that is unreachable answers 502 rather than a stale `false`. The one exception is a revoked record: it short-circuits to `status: \"revoked\"` and `charges_enabled: false` without calling the provider at all, since there is nothing left to poll. A confirmed account that never reached an upstream account ref reports not-yet-chargeable for the same reason.\n\n`capabilities` carries the provider's per-capability activation states when it reports them, and is absent when it does not. An account can be provisioned and still not chargeable; gate any money movement on `charges_enabled`, not on the existence of the connection.\n\nSame dual-id resolution and same cross-tenant 404 as the revoke beside it, minus its admin floor: a read does not need one.","parameters":[{"schema":{"type":"string","description":"A `ca_` connection id, or a `pr_` provisioning record id on the operator-internal path."},"required":true,"description":"A `ca_` connection id, or a `pr_` provisioning record id on the operator-internal path.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["provisioned","revoked"]},"charges_enabled":{"type":"boolean"},"capabilities":{"type":"object","additionalProperties":{"type":"string"},"description":"Per-capability activation states as the provider reports them, for example `card_payments: \"active\"`."},"account_ref":{"type":"string","description":"The upstream account identifier. Empty string when the record never reached one."}},"required":["status","charges_enabled","account_ref"]}}}},"404":{"description":"No connection or operator-internal record with that id is reachable by this caller.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"}},"required":["code","message"]}},"required":["error"]}}}},"500":{"description":"The status read threw unexpectedly.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["status_failed"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"502":{"description":"The provider read did not produce a status. Usually the live call failed or timed out, and a retry is the right response. TWO OF THE CASES BEHIND THIS CODE ARE NOT TRANSIENT and retrying never clears them: the provider has no subaccount descriptor in this deployment, and no platform account credential is registered for it. Both are configuration defects on our side; `error.message` distinguishes them from a provider outage.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["status_failed"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/consumers/{consumerId}/dda/subscriptions/{document}":{"get":{"summary":"Read a DDA registration","description":"Whether this consumer's document is registered with DDA, Brazil's national boleto-visibility register, and what state the registration is in. A pure local read: there is nothing to poll upstream, because the provider's confirmation webhook is the only thing that moves the status off `pending`.\n\nThe path parameter accepts a punctuated or bare CPF/CNPJ; the digits are what is matched. A value carrying no digits is 400. Another organization's registration answers 404, not 403, so its existence cannot be probed. Scoped to the organization, not the project: the underlying table carries no project column.","parameters":[{"schema":{"type":"string"},"required":true,"name":"consumerId","in":"path"},{"schema":{"type":"string","description":"CPF or CNPJ. Punctuation is accepted and stripped."},"required":true,"description":"CPF or CNPJ. Punctuation is accepted and stripped.","name":"document","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"document":{"type":"string","description":"Digits only. The stored form, which is what the provider leg was sent."},"status":{"type":"string","enum":["pending","active","error","deleted"],"description":"`pending` until the provider's confirmation webhook lands. Nothing polls the provider for this: the webhook is the only source of a status change, so a registration can sit `pending` indefinitely if a delivery was lost."},"client_request_id":{"type":"string","description":"The idempotency correlation carried to the provider on the registration."},"celcoin_subscription_id":{"type":["string","null"],"description":"The provider's own id, null until the confirmation webhook supplies it."}},"required":["document","status","client_request_id","celcoin_subscription_id"]}}}},"400":{"description":"The path's document carries no CPF/CNPJ digits.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"No DDA registration for this consumer and document. Also the answer for a consumer another organization holds.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"delete":{"summary":"End a DDA registration","description":"Withdraws the document from the national register, then marks the local row `deleted`. The local row and its mirrored boleto history are kept, not dropped.\n\nDeliberately NOT consent-gated, unlike the registration: a consumer who revoked their mandate must still be able to withdraw a document registered in their name, and gating this would trap them in the exposure. Existence is the tenancy guard, which is also what stops a caller sending the provider a delete for a document its organization never registered.\n\nConvergent rather than idempotency-keyed: the target state is `not registered`, which repetition cannot overshoot. A row already `deleted` answers success without calling the provider again.","parameters":[{"schema":{"type":"string"},"required":true,"name":"consumerId","in":"path"},{"schema":{"type":"string","description":"CPF or CNPJ. Punctuation is accepted and stripped."},"required":true,"description":"CPF or CNPJ. Punctuation is accepted and stripped.","name":"document","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"document":{"type":"string"},"status":{"type":"string","enum":["deleted"]}},"required":["document","status"]}}}},"400":{"description":"The path's document carries no CPF/CNPJ digits.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"No DDA registration for this consumer and document. Also returned when the row disappeared between the ownership read and the provider call, because in that case nothing was sent upstream.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"502":{"description":"The registration could not be ended upstream. Nothing about the local row changed; retry later.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["dda_provider_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/consumers/{consumerId}/dda/boletos":{"get":{"summary":"The boletos DDA has mirrored for this consumer","description":"Every boleto issued nationwide against a document this consumer has registered, pushed to us and mirrored locally, filtered to a due-date window. This is the discovery step: it is what lets an agent find a bill instead of being told about it.\n\nA pure read of OUR mirror. DDA has no pull endpoint, so this never calls the provider and can never be stale because the provider is down. What it does not contain is what was never pushed to us.\n\nCovers ALL of the consumer's registered documents at once, since a consumer can hold both a CPF and a CNPJ registration. Both dates are required, inclusive, `YYYY-MM-DD`. Results are ordered by due date ascending, nulls last. No pagination and no limit: the window is the only bound.\n\nTHE WINDOW AND THE ANSWER DO NOT USE THE SAME DATE FORMAT. `from_date` and `to_date` go in as `YYYY-MM-DD` and are echoed back in that form, but each boleto's `due_date` comes back as a full ISO-8601 date-time at midnight UTC. See the field's own note for why, and do not round-trip a `due_date` straight back into `from_date`.","parameters":[{"schema":{"type":"string"},"required":true,"name":"consumerId","in":"path"},{"schema":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},"required":true,"name":"from_date","in":"query"},{"schema":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},"required":true,"name":"to_date","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"from_date":{"type":"string","description":"Echoes the validated `from_date` query parameter, so exactly `YYYY-MM-DD`."},"to_date":{"type":"string","description":"Echoes the validated `to_date` query parameter, so exactly `YYYY-MM-DD`."},"count":{"type":"integer","description":"The length of `boletos`. No pagination, so this is the total for the window."},"boletos":{"type":"array","items":{"type":"object","properties":{"linha_digitavel":{"type":"string"},"due_date":{"type":["string","null"],"format":"date-time","description":"A full ISO-8601 UTC date-time, NOT the `YYYY-MM-DD` the window parameters take. For example `2026-09-08T00:00:00.000Z`. Do not write a parser that expects ten characters.\n\nThe asymmetry is a driver artifact and it is worth knowing rather than working around blindly. The column is a SQL `date` (`due_date date`, OID 1082). The driver registers a parser for that OID that builds a JavaScript `Date`, this API installs no type override for it, and nothing between the query and the response converts the value back, so the `Date` is serialized by `toJSON`, which is `toISOString`.\n\nThe time-of-day and the zone therefore carry NO information: a SQL `date` has neither. It is always midnight UTC. Take the first ten characters to recover the calendar day, and do not shift the value into a local zone first: west of UTC that lands you on the previous day.\n\nNull when the mirrored boleto arrived without a due date; those sort last."},"original_amount_minor":{"type":["integer","null"],"description":"Centavos."},"beneficiary":{"type":["string","null"]},"status":{"type":"string","description":"The provider's own vocabulary, stored verbatim and not normalized (`Aberto`, `Baixa efetiva`, and whatever else it sends). Deliberately not a closed set: a value we have not seen yet round-trips instead of being coerced."}},"required":["linha_digitavel","due_date","original_amount_minor","beneficiary","status"]}}},"required":["from_date","to_date","count","boletos"]}}}},"400":{"description":"`from_date` or `to_date` is missing or is not YYYY-MM-DD. `details.issues` carries the validation issues.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_query"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/consumers/{consumerId}/pix-keys":{"get":{"summary":"List the Pix keys on the consumer's account","description":"The account's live DICT inventory, read from the provider on every call. The account is always resolved server-side from the credential's organization and the consumer id, never taken from the caller, so this can only ever reach an account the caller's own organization holds.\n\nA failed inventory read is an ERROR, never an empty list. `unanswered` is not `no keys`, and rendering it as an empty account is the mistake this route refuses to make: a caller that read zero keys would go on to register a key the account may already have, spending one of its limited DICT slots.","parameters":[{"schema":{"type":"string"},"required":true,"name":"consumerId","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"account_masked":{"type":"string","description":"Four mask characters followed by the account's last two digits."},"keys":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"key_type":{"type":"string","description":"Read off the provider's inventory answer and passed through as-is. Expect `EVP`, `CPF`, `CNPJ`, `EMAIL` or `PHONE`; not a closed set here, because this value is the provider's string rather than one this API validates."}},"required":["key","key_type"]}},"count":{"type":"integer"}},"required":["account_masked","keys","count"]}}}},"422":{"description":"`no_celcoin_account`: the consumer has no active Pix funding source, so there is no account to list. `pix_key_provider_refused`: the provider adjudicated and said no; `details.provider_code` names its business code, and retrying the same input cannot succeed.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["no_celcoin_account","pix_key_provider_refused"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"502":{"description":"The listing could not be completed upstream. The outcome is unknown and the condition is transient; retry. `details.provider_code` still carries whatever code the body named, so a generic provider error is visible without being called terminal.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["pix_key_provider_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"post":{"summary":"Register a Pix key on the consumer's account","description":"Registers a DICT entry on the consumer's account. The account is ALWAYS resolved on the server from (organization, `consumerId`) — there is no account field in the body — so a caller can only ever reach an account of a consumer its own organization holds.\n\n`key_type` is explicit, EVP included, so nobody mints a random key by omission. For EVP the `key` field must be ABSENT (the provider mints it); for EMAIL, PHONE, CPF and CNPJ it is the key value. A CNPJ key is 14 ALPHANUMERIC characters, which is the DICT rule in force, not 14 digits.\n\nA CPF/CNPJ KEY MAY ONLY BE THE HOLDER'S OWN DOCUMENT, which is a DICT rule. The check is against the document recorded on the account and it fails closed whenever that document is known; where the record predates it and carries none, the provider adjudicates.\n\nAN EMAIL/PHONE KEY MAY REQUIRE PROOF OF POSSESSION: while that check is enforced on the deployment, the contact must have been verified first through `POST /v1/consumers/{consumerId}/contact-verifications`, and its absence answers 409 `pix_key_ownership_unverified` before the provider is called at all.\n\nTHE 409 `pix_key_registration_uncertain` IS THE CASE TO READ BEFORE INTEGRATING: the registration was accepted, the response was unreadable, and the account's key list could not settle whether the key landed. DO NOT REPEAT IT. Every repeated EVP mints ANOTHER key against the account's five-key allowance. List the keys with the GET on this same path and delete whatever should not be there.\n\n422 AGAINST 502 IS ALSO DELIBERATE: 422 `pix_key_provider_refused` is the provider having ADJUDICATED and said no, and repeating the same input cannot succeed; 502 `pix_key_provider_unavailable` is transient and retriable. Their `details` differ, and the 502 block below says how — that field is not uniform across the three failures that produce it.","parameters":[{"schema":{"type":"string"},"required":true,"name":"consumerId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"key_type":{"type":"string","enum":["EVP","CPF","CNPJ","EMAIL","PHONE"]},"key":{"type":"string","minLength":1,"maxLength":140}},"required":["key_type"]}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"key":{"type":"string","description":"The registered key. For EVP this is the random key the provider minted, and this response is the only place it appears in this call."},"key_type":{"type":"string","description":"The type as the provider returned it. Deliberately an open string rather than the request's enum: the value comes back from upstream and is carried through untyped."},"account_masked":{"type":"string","description":"The account, masked to its last two digits."},"cache_filled":{"type":"boolean","description":"false when the key WAS registered but the local cache could not be filled. Not a registration failure."},"reconciled":{"type":"boolean","description":"true when the key returned here came from re-reading the account's key list, because the registration response was unreadable. The key belongs to the account; THIS call is not proven to be what created it."}},"required":["key","key_type","account_masked","cache_filled","reconciled"]}}}},"400":{"description":"`invalid_body`: the body did not match the schema (`details.issues`). `pix_key_invalid`: the value is not a valid key for the `key_type` asked for, or `key` came with an EVP request, or `key` was missing for every other type. Neither reached the provider.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body","pix_key_invalid"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"403":{"description":"`pix_key_document_mismatch`: a CPF/CNPJ key whose value is not the account holder's own document. Refused locally, without calling the provider.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["pix_key_document_mismatch"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"`pix_key_ownership_unverified`: this e-mail or phone has no recent verification for this consumer. `details` carries the channel, the endpoint to call and the window a verification stays good for; start a contact verification, confirm the code, retry. Nothing reached the provider.\n\n`pix_key_registration_uncertain`: the registration was accepted and came back with no readable key, and the account's key list could not settle whether it landed. `details` carries `retry_safe: false`, `inventory_known` and `inventory_count`. LIST the keys instead of repeating the registration.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["pix_key_ownership_unverified","pix_key_registration_uncertain"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"422":{"description":"`no_celcoin_account`: the consumer has no active pix-celcoin funding source in this organization — or is not this organization's consumer at all; nothing reached the provider. `pix_key_provider_refused`: the provider adjudicated and refused, with `details.provider_status` and `details.provider_code` (null when the body named no code); repeating the same input will not pass.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["no_celcoin_account","pix_key_provider_refused"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"500":{"description":"NOT the usual error envelope. Resolving the consumer's account runs before the handler's own error handling, so a failure there is answered by the framework's default: `{ statusCode, error, message }`, plus `code` when the underlying failure carried one. It happens before the provider is called, so nothing was registered.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["pix_key_provider_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]},{"type":"object","properties":{"statusCode":{"type":"number","enum":[500]},"error":{"type":"string","enum":["Internal Server Error"]},"message":{"type":"string"},"code":{"type":"string","description":"Present only when the underlying failure carried one."}},"required":["statusCode","error","message"]}]}}}},"502":{"description":"`pix_key_provider_unavailable`, and THREE different failures produce it with THREE different `details`. Read the field, not the code.\n\n1. A transient upstream failure — a 5xx, transport, credentials, a generic internal code, a codeless 429/408/404. `details.provider_status` and `details.provider_code` (null when the body named no code).\n\n2. A registration that PROVABLY did not land: the account's key list answered and does not carry the key. `details.verified_not_registered` is true and `details.inventory_count` is the number of keys the list reported. There is NO `provider_code` here — no provider code was involved in reaching this conclusion.\n\n3. Anything the handler did not classify. No `details` at all.\n\nAll three are retriable; only the second one is retriable on evidence rather than on the absence of it.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["pix_key_provider_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/consumers/{consumerId}/pix-keys/{key}":{"delete":{"summary":"Delete a Pix key from the consumer's account","description":"Removes the key from DICT, then reconciles the local cache of the consumer's receiving key. The key is canonicalized before both steps, so a CPF registered bare and deleted punctuated is the same key.\n\nThe cache reconciliation is part of the contract, not a detail. An empty cache is what makes the wallet mint a fresh random EVP nobody asked for, burning a DICT slot and switching the consumer onto a key they did not choose. So when the delete clears the cache, the account's remaining keys are re-read and one is cached back, preferring a non-EVP key. `cache_refilled_with` reports which. The just-deleted value is excluded from the candidates, because DICT releases a deleted entry asynchronously and can still list it for a minute or more.\n\nThe key travels in a path segment. Fastify percent-decodes it, and a literal `+` in a path segment stays a `+`, so e-mail and phone keys are safe either way.","parameters":[{"schema":{"type":"string"},"required":true,"name":"consumerId","in":"path"},{"schema":{"type":"string","description":"The key value. Trimmed; an empty value is 400."},"required":true,"description":"The key value. Trimmed; an empty value is 400.","name":"key","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"key":{"type":"string","description":"The canonical form actually sent upstream. A punctuated CPF or CNPJ is normalized before the delete, so this can differ from the value in the path."},"account_masked":{"type":"string"},"deleted":{"type":"boolean","enum":[true]},"cache_cleared":{"type":"boolean","description":"Whether the deleted key was the one this API had cached as the receiving key."},"cache_refilled_with":{"type":["string","null"],"description":"When the cache was cleared, the account's remaining keys are re-read and one is cached back, preferring a non-EVP key. Null when nothing was cleared, nothing remains, or the inventory read did not answer."}},"required":["key","account_masked","deleted","cache_cleared","cache_refilled_with"]}}}},"400":{"description":"The key in the path is empty after trimming.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"422":{"description":"`no_celcoin_account`: the consumer has no active Pix funding source. `pix_key_provider_refused`: the provider adjudicated and refused; retrying the same input cannot succeed. `details.provider_code` carries its business code.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["no_celcoin_account","pix_key_provider_refused"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"502":{"description":"The deletion could not be completed upstream. Whether the key is gone is unknown; retry, then list the keys to confirm.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["pix_key_provider_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/consumers/{consumerId}/pix/charges/{reference}":{"get":{"summary":"A minted Pix charge's state","description":"Our books for the charge, plus the provider's live state for a PENDING dynamic cob. READ ONLY: this never credits. Crediting stays with the webhook and the reconciler.\n\n`reference` resolves against three columns: the charge id, the provider transaction id recorded at mint, and the client request id. A value that matches two different charges is 409 rather than an answer for whichever came first.\n\n`provider` is null in two honest cases, and the difference matters. A charge already settled or expired on our books is terminal here, and a provider consult would add nothing. A static cob carries no identifier the homologated consult accepts, so whether it is still payable cannot be verified at all, and settlement for it can only arrive by webhook.\n\n`provider.result: \"not_found\"` is an answer about the COB, not proof the payment never happened. Confirm the payment itself with the receivement query, using the end-to-end id from the payer's receipt, before minting a replacement charge. Minting one on a not-found can bill the payer twice.","parameters":[{"schema":{"type":"string"},"required":true,"name":"consumerId","in":"path"},{"schema":{"type":"string","description":"The charge id, the mint's transaction id, or its client request id. Up to 128 characters of letters, digits, `_` and `-`."},"required":true,"description":"The charge id, the mint's transaction id, or its client request id. Up to 128 characters of letters, digits, `_` and `-`.","name":"reference","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"charge_id":{"type":"string"},"status":{"type":"string","description":"Our books: `pending`, `settled` or `expired`. Left open rather than enumerated because the value is carried as a plain string end to end."},"amount_minor":{"type":"integer"},"currency":{"type":"string"},"transaction_id":{"type":["string","null"],"description":"The provider transaction id recorded at mint."},"client_request_id":{"type":["string","null"],"description":"Null for a static cob."},"wallet_id":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"settled_at":{"type":["string","null"],"format":"date-time"},"provider":{"type":["object","null"],"properties":{"result":{"type":"string","enum":["ok","not_found"]},"status":{"type":["string","null"],"description":"The cob's live server-side state."},"transaction_id":{"type":["string","null"]},"identity_verified":{"type":"boolean","description":"True only when the consult's transactionId and the mint's are BOTH present and equal. False means the check could not run, never that it ran and passed."},"last_update":{"type":["string","null"]},"create_at":{"type":["string","null"]},"expiration_seconds":{"type":["number","null"]},"revision":{"type":["string","null"]}},"required":["result","status","transaction_id","identity_verified","last_update","create_at","expiration_seconds","revision"],"description":"Null when the provider was not consulted: the charge is already terminal on our books, or it is a static cob carrying no identifier the consult accepts."},"status_message":{"type":"string","description":"Prose written for the caller, explaining what the combination above does and does not prove."}},"required":["charge_id","status","amount_minor","currency","transaction_id","client_request_id","wallet_id","created_at","settled_at","provider","status_message"]}}}},"400":{"description":"`reference` does not look like any of the three identifiers this route resolves.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_charge_reference"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"No Pix charge with this reference. Deliberately the same answer for a charge another organization holds.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"The reference matches more than one charge, because one value can sit in one row's transaction id and another row's client request id. Query by the charge id instead.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["charge_reference_ambiguous"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"500":{"description":"The charge status could not be read.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["pix_status_error"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"502":{"description":"The provider consult did not answer, or it answered for a DIFFERENT cob than this charge and was refused rather than reported as this charge's state. Nothing about the charge was changed; retry later. `details.reason` names which of the two it was.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["pix_status_provider_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/consumers/{consumerId}/pix/receivements/{endToEndId}":{"get":{"summary":"Did this specific Pix land on this consumer's account","description":"Asks the provider about one inbound payment by its end-to-end id and, when it is a receivement on this consumer's OWN account, correlates it to a charge on our books. READ ONLY: this never credits.\n\nThe tenant binding is provider truth, not our own record: the answer's credit-party account must equal the account we resolved for this consumer. A mismatch answers exactly the same 404 a genuine miss does, so a bearer end-to-end id cannot become a probe for another tenant's receivements. An answer this backend cannot bind at all, or cannot classify, is 502 rather than a guess in either direction.\n\nA 404 is not proof the payment never arrived. The provider indexes a receivement up to about two minutes after settlement, usually under a minute, so a just-paid Pix can genuinely answer not-found. Retry shortly before concluding anything.\n\n`received: false` with a `provider_transaction_type` named is a real answer, not a failure: a movement exists under this id on this account but is not an inbound receivement. A reversal of a Pix the consumer SENT credits the same account and looks like this.","parameters":[{"schema":{"type":"string"},"required":true,"name":"consumerId","in":"path"},{"schema":{"type":"string","description":"The payment's end-to-end id, the reference the payer's receipt carries. 16 to 64 alphanumeric characters."},"required":true,"description":"The payment's end-to-end id, the reference the payer's receipt carries. 16 to 64 alphanumeric characters.","name":"endToEndId","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"end_to_end_id":{"type":"string"},"received":{"type":"boolean","description":"True only when the provider classifies the movement as the homologated inbound type. A movement of another type on the same account (a reversal of a Pix the consumer SENT, for one) answers false with the type named below."},"provider_transaction_type":{"type":"string","description":"The provider's discriminator, verbatim."},"provider_amount":{"description":"The provider's own representation of the amount, passed through unconverted."},"initiation_type":{"type":["string","null"]},"provider_transaction_id":{"type":["string","null"]},"transaction_id_brcode":{"type":["string","null"]},"transaction_identification":{"type":["string","null"]},"correlation":{"type":"string","enum":["charge_matched","no_correlator_returned","no_charge_matched","charge_ambiguous","not_attempted"],"description":"How this receivement was tied to a charge on our books. None of the empty outcomes proves a credit is not coming: the reconciler matches the account STATEMENT on a field this consult never returns."},"charge_id":{"type":["string","null"]},"charge_status":{"type":["string","null"]},"wallet_id":{"type":["string","null"]},"amount_minor":{"type":["integer","null"],"description":"Minor-unit truth from OUR charge row. Null when uncorrelated."},"wallet_credited":{"type":"boolean"},"devolutions":{"type":"array","items":{"type":"object","properties":{"devolution_id":{"type":"string"},"status":{"type":"string"},"amount_minor":{"type":"integer"}},"required":["devolution_id","status","amount_minor"]},"description":"Our devolution rows keyed on this end-to-end id, whatever the movement type is."},"status_message":{"type":"string"}},"required":["end_to_end_id","received","provider_transaction_type","initiation_type","provider_transaction_id","transaction_id_brcode","transaction_identification","correlation","charge_id","charge_status","wallet_id","amount_minor","wallet_credited","devolutions","status_message"]}}}},"400":{"description":"`endToEndId` does not have the shape of an end-to-end id.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_end_to_end_id"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"No receivement with this end-to-end id on this consumer's account. Also the answer when the movement exists but belongs to another account, which is what keeps a bearer identifier from probing across tenants.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["receivement_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"The consumer has no active Pix funding source, so there is no account to bind the answer to. Checked BEFORE the provider is queried, so an unbindable consumer never causes a lookup on a bearer identifier.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["funding_source_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"500":{"description":"The receivement status could not be read.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["pix_status_error"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"502":{"description":"The provider consult did not answer, or answered in a shape this backend cannot tenant-bind or cannot classify. Fail-closed in all three cases; `details.reason` names which.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["pix_status_provider_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/consumers/{consumerId}/pix/devolutions/{devolutionId}":{"get":{"summary":"A Pix devolution's state","description":"The recorded devolution, with settlement resolved from the consumer's own account statement when the recorded state is not yet terminal.\n\nThe statement is the only provider truth available here: the provider's status poll never resolves reversals, so reading the reversal movement on the consumer's own statement is what settles the row. A statement fetch that fails degrades to `provider_truth: \"unavailable\"` and returns the recorded state, never an error: the row itself is always answerable.\n\nA devolution belonging to another organization, project or consumer answers the same 404 an unknown id does.","parameters":[{"schema":{"type":"string"},"required":true,"name":"consumerId","in":"path"},{"schema":{"type":"string"},"required":true,"name":"devolutionId","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"devolution_id":{"type":"string"},"status":{"type":"string","enum":["pending","accepted","settled","failed","uncertain"],"description":"The five values the table's CHECK constraint admits."},"payment_direction":{"type":"string","enum":["inbound","outbound"],"description":"Which side of the rail the ORIGINAL payment was on. `inbound`: the consumer received it and the money goes back to its payer. `outbound`: the consumer sent it and the money returns to the consumer."},"end_to_end_id":{"type":"string"},"amount_minor":{"type":"integer"},"currency":{"type":"string","enum":["BRL"]},"reason":{"type":"string","description":"The Pix return code."},"client_code":{"type":"string","description":"The server-minted idempotency correlation sent upstream."},"return_identification":{"type":["string","null"]},"original_payment_id":{"type":["string","null"]},"wallet_id":{"type":["string","null"]},"charge_id":{"type":["string","null"]},"failure_reason":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time"},"settled_at":{"type":["string","null"],"format":"date-time"},"idempotent_replay":{"type":"boolean","description":"Always false on this read. The field is meaningful on the create, not here."},"status_message":{"type":"string"},"provider_truth":{"type":"string","enum":["statement","unavailable","not_checked"],"description":"Where the state above came from. `not_checked`: the row is already terminal. `statement`: the consumer's own account statement was read and settled the row if it showed the reversal. `unavailable`: the statement could not be fetched, so the recorded state is returned unchanged. A statement failure degrades this field; it never turns the read into an error."}},"required":["devolution_id","status","payment_direction","end_to_end_id","amount_minor","currency","reason","client_code","return_identification","original_payment_id","wallet_id","charge_id","failure_reason","created_at","settled_at","idempotent_replay","status_message","provider_truth"]}}}},"404":{"description":"Not a known devolution for this consumer. The same opaque answer for a truly unknown id and for another tenant's.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["devolution_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"500":{"description":"The devolution status could not be read.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["devolution_error"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/consumers/{consumerId}/fund/{txId}":{"get":{"summary":"Poll an onramp, and credit the wallet when it settles","description":"THIS GET WRITES. It polls the ramp provider for the transaction's state and, when that state is a terminal success AND the delivered amount is positive, posts the credit to the consumer's wallet ledger. There is no separate confirm call: polling is the credit-on-settle path.\n\nSafe to poll on a loop. The credit deduplicates on a deterministic external reference derived from the transaction id, so re-polling a settled transaction never double-credits. `credited` reports that this poll posted a credit, and it stays true on every later poll of the same settled transaction, because the same conditions still hold and the repeat post is a no-op. It is not a `this call was the first` flag.\n\nUntil the deposit is confirmed the status stays at the provider's pending value, the amount is not yet meaningful, and `credited` is false. A settled transaction that delivered a zero amount also stays `credited: false`, on purpose.\n\nThe provider connection is resolved by ORGANIZATION. A project match is only a tie-break preference when the organization holds more than one connection, never a filter, so this route is not scoped to the calling project the way the receipts routes are.","parameters":[{"schema":{"type":"string"},"required":true,"name":"consumerId","in":"path"},{"schema":{"type":"string","description":"The ramp provider's transaction id, from the fund call."},"required":true,"description":"The ramp provider's transaction id, from the fund call.","name":"txId","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"transaction_id":{"type":"string","description":"Echoes the path parameter."},"status":{"type":"string","description":"The ramp provider's status, lower-cased and passed through (`awaiting_deposit`, `processing`, `completed`, and others it may add). Not a closed set."},"amount_minor":{"type":"integer","description":"USDC the wallet receives, in minor units. Only meaningful once the tx settles."},"currency":{"type":"string"},"credited":{"type":"boolean","description":"True whenever this poll found the transaction in a terminal success state with a positive delivered amount, and therefore posted the ledger credit. It is true on EVERY such poll, not only the first: the credit deduplicates on a deterministic external reference derived from the transaction id, so the repeat post is a no-op. Read it as `this transaction has settled and the credit exists`, never as `this call created the credit`."}},"required":["transaction_id","status","amount_minor","currency","credited"]}}}},"404":{"description":"One code, and THREE distinct failures collapse into it. The route answers 404 exactly when the provider lookup yields nothing, and that lookup gives up in three separate places:\n\n1. NOT CONNECTED. The organization has no ramp-provider account in `connected` state. A caller that never completed the provider connection sees this on every poll, for every transaction id.\n\n2. CREDENTIAL GONE. The connection row exists and still reads as connected, but its stored credential no longer dereferences: revoked, rotated, or vaulted under a scope that no longer matches the row. Nothing else about the connection looks broken, which is what makes this one hard to recognize from the outside.\n\n3. THE PROVIDER DID NOT RETURN IT. Any non-2xx from the provider lands here. That covers both `this credential has no such transaction` and `the provider is down or refused`, and the two are NOT distinguished: an outage is indistinguishable from an unknown id at this boundary.\n\nOnly the third is worth retrying, and the response does not say which one happened. So read a 404 as `this API cannot currently see the transaction`, never as `the transaction does not exist`, and in particular never as grounds for starting a second onramp for the same deposit.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["transaction_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/consumers/{id}/wallet":{"get":{"summary":"The consumer's unified wallet, rolled up per currency","description":"One line per currency the consumer has spend authority in, each carrying what the agent is authorized to spend, what it has spent, and what is left.\n\nCaps are PER CURRENCY and no FX is ever applied. A BRL authorization and a USDC authorization are separate slots with separate ceilings, and nothing here converts one into the other. `available_minor` is authorized minus spent, floored at zero.\n\n`authorized_minor` is a CEILING, not a balance and not a charge: it is the sum of the slot caps across the consumer's active mandates, so it says what may be spent, not what exists. `spent_minor` counts settled debits only.\n\nLegacy single-currency mandates fold in as a one-currency wallet, so the shape is uniform. A consumer with no mandates answers 200 with an empty `currencies` array, not 404: this route asserts nothing about whether the consumer exists.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"consumer_id":{"type":"string"},"currencies":{"type":"array","items":{"type":"object","properties":{"currency":{"type":"string"},"rail":{"type":["string","null"],"description":"The provider-agnostic method (`pix`, `usdc`), never the institution beneath it."},"authorized_minor":{"type":"integer","description":"Sum of the slot caps across the consumer's active mandates for this currency."},"spent_minor":{"type":"integer","description":"Sum of settled debits in this currency."},"available_minor":{"type":"integer","description":"authorized minus spent, floored at 0."},"funding_source_ids":{"type":"array","items":{"type":"string"}},"mandate_ids":{"type":"array","items":{"type":"string"}}},"required":["currency","rail","authorized_minor","spent_minor","available_minor","funding_source_ids","mandate_ids"]}}},"required":["consumer_id","currencies"]}}}}}}},"/v1/consumers/mandates/{id}/card":{"get":{"summary":"The card bound to a mandate, and its recent authorizations","description":"The active card binding for this allowance plus the card's recent authorization decisions, newest first. A mandate with no active binding answers 200 with `card: null` and an empty `authorizations` array, which is the shape to branch on; 404 is reserved for the mandate itself not being visible to this caller.\n\n`limit` bounds the authorizations, not the card. It is clamped rather than validated: a value above 100 becomes 100, a value below 1 becomes 1, and anything that does not parse as a number falls back to 20. No input to it is rejected.","parameters":[{"schema":{"type":"string","description":"The mandate id."},"required":true,"description":"The mandate id.","name":"id","in":"path"},{"schema":{"type":"integer","description":"Authorizations to return. Default 20, clamped to the range 1 to 100."},"required":false,"description":"Authorizations to return. Default 20, clamped to the range 1 to 100.","name":"limit","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"mandate_id":{"type":"string"},"card":{"type":["object","null"],"properties":{"card_id":{"type":"string"},"consumer_id":{"type":"string"},"affinity_group_id":{"type":["string","null"]},"cardholder_id":{"type":["string","null"]},"last_four":{"type":["string","null"]},"brand":{"type":["string","null"]},"status":{"type":"string"},"bound_at":{"type":"string","format":"date-time"}},"required":["card_id","consumer_id","affinity_group_id","cardholder_id","last_four","brand","status","bound_at"],"description":"Null when the mandate exists and has no active card binding."},"authorizations":{"type":"array","items":{"type":"object","properties":{"transaction_id":{"type":"string"},"amount_minor":{"type":"string","description":"A decimal STRING, not a number: the column is read as text to keep precision."},"currency":{"type":"string"},"merchant_name":{"type":["string","null"]},"mcc":{"type":["string","null"]},"operation":{"type":["string","null"]},"decision":{"type":"string"},"status_detail":{"type":["string","null"]},"remaining_minor":{"type":["string","null"],"description":"A decimal string, like amount_minor."},"at":{"type":"string","format":"date-time"}},"required":["transaction_id","amount_minor","currency","merchant_name","mcc","operation","decision","status_detail","remaining_minor","at"]},"description":"Newest first. Empty when no card is bound."}},"required":["mandate_id","card","authorizations"]}}}},"404":{"description":"No such mandate for this organization. A mandate another organization holds answers the same way.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["mandate_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"delete":{"summary":"Unbind the card from a mandate","description":"Closes the binding, which frees the slot so a different card can be bound and makes the card authorizer fail closed for that card, since it only governs ACTIVE bindings.\n\nThis does NOT cancel the card at the issuer. It unbinds the card from the allowance and nothing more. For a full kill switch, cancel the card at the issuer as well.\n\nNot idempotent in the way a delete usually is: the second call answers 404 `no_active_card`, because after the first there is no active binding left to revoke. The two 404 codes are worth telling apart, and are: `mandate_not_found` means this caller cannot see the mandate at all, `no_active_card` means the mandate is there and carries nothing to unbind.","parameters":[{"schema":{"type":"string","description":"The mandate id."},"required":true,"description":"The mandate id.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"mandate_id":{"type":"string"},"card_id":{"type":"string"},"revoked":{"type":"boolean","enum":[true]}},"required":["mandate_id","card_id","revoked"]}}}},"404":{"description":"`mandate_not_found`: no such mandate for this organization, which is also the answer for another organization's mandate. `no_active_card`: the mandate exists and has no active card binding to revoke.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["mandate_not_found","no_active_card"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/consumers/{consumerId}/receipts":{"get":{"summary":"A consumer's agentic receipts","description":"Every sealed receipt for this consumer, newest first. The receipt is the control record of an agent payment: mandate, quote, payment and delivery, hash-chained and signed.\n\nScoped to the credential's PROJECT as well as its organization, because a project is the environment boundary and a receipt carries signed payment evidence. A live key does not see a test project's receipts, and the reverse.\n\nReturns at most 50 receipts and takes no query parameters: there is no `limit`, no cursor and no filter on this route today. A consumer with no receipts, and a consumer this project cannot see, both answer 200 with an empty array.","parameters":[{"schema":{"type":"string"},"required":true,"name":"consumerId","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"receipts":{"type":"array","items":{"type":"object","properties":{"receipt_id":{"type":"string"},"state":{"type":"string","enum":["paid","delivered","exception","voided"]},"mandate":{"type":"object","properties":{"id":{"type":"string"},"nonce":{"type":"string"},"scope":{"type":"string"},"currency":{"type":"string"},"sig":{"type":"string"}},"required":["id","nonce","scope","currency","sig"]},"quote":{"type":["object","null"],"properties":{"seller":{"type":["string","null"]},"resource":{"type":["string","null"]},"price_minor":{"type":["number","null"]},"payee":{"type":["string","null"]},"session_id":{"type":["string","null"]},"sig":{"type":["string","null"]},"at":{"type":["string","null"]}},"required":["seller","resource","price_minor","payee","session_id","sig","at"],"description":"Null when the receipt carries neither a seller nor a resource."},"payment":{"type":"object","properties":{"rail":{"type":"string"},"provider":{"type":["string","null"]},"tx_id":{"type":["string","null"]},"amount_minor":{"type":"number"},"amount_atomic":{"type":["string","null"],"description":"Exact atomic amount for crypto rails; null for fiat."},"amount_authorized":{"type":"string"},"amount_charged":{"type":"string"},"amount_refunded":{"type":"string"},"metering":{"type":"object","properties":{"basis":{"type":"string"},"units":{"type":"string"},"unit_price":{"type":"string"}},"required":["basis","units","unit_price"],"description":"Present only on a metered receipt. The three amounts above travel with it."},"sandbox":{"type":"boolean","enum":[true],"description":"Present, and always true, ONLY when the settlement was simulated."},"attempt_id":{"type":"string"},"money_moved":{"type":"boolean"},"at":{"type":"string"}},"required":["rail","provider","tx_id","amount_minor","amount_atomic","attempt_id","money_moved","at"]},"delivery":{"type":["object","null"],"properties":{"result":{"type":"string","enum":["confirmed","pending","failed"]},"proof":{"type":["string","null"]},"kind":{"type":["string","null"]},"nfe_chave":{"type":["string","null"]},"at":{"type":["string","null"]}},"required":["result","proof","kind","nfe_chave","at"],"description":"Null until a delivery proof has been sealed onto the receipt."},"chain":{"type":"string"},"receipt_sig":{"type":"string"},"exceptions":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string"},"detail":{"type":"string"},"at":{"type":"string"}},"required":["code","detail","at"]}}},"required":["receipt_id","state","mandate","quote","payment","delivery","chain","receipt_sig","exceptions"]}}},"required":["receipts"]}}}}}}},"/v1/consumers/receipts/{id}":{"get":{"summary":"Read one agentic receipt","description":"The same shape the list returns, by receipt id. Project-scoped for the same reason the list is: the id comes from the path and the row carries signed payment evidence, so organization alone would let a live project read a test project's receipts.\n\n`chain` and `receipt_sig` are what make this evidence rather than a record: the four links are hash-chained and the result is signed. `state` moves from `paid` to `delivered` when a delivery proof is sealed onto it, which re-anchors the chain.","parameters":[{"schema":{"type":"string","description":"The receipt id."},"required":true,"description":"The receipt id.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"receipt_id":{"type":"string"},"state":{"type":"string","enum":["paid","delivered","exception","voided"]},"mandate":{"type":"object","properties":{"id":{"type":"string"},"nonce":{"type":"string"},"scope":{"type":"string"},"currency":{"type":"string"},"sig":{"type":"string"}},"required":["id","nonce","scope","currency","sig"]},"quote":{"type":["object","null"],"properties":{"seller":{"type":["string","null"]},"resource":{"type":["string","null"]},"price_minor":{"type":["number","null"]},"payee":{"type":["string","null"]},"session_id":{"type":["string","null"]},"sig":{"type":["string","null"]},"at":{"type":["string","null"]}},"required":["seller","resource","price_minor","payee","session_id","sig","at"],"description":"Null when the receipt carries neither a seller nor a resource."},"payment":{"type":"object","properties":{"rail":{"type":"string"},"provider":{"type":["string","null"]},"tx_id":{"type":["string","null"]},"amount_minor":{"type":"number"},"amount_atomic":{"type":["string","null"],"description":"Exact atomic amount for crypto rails; null for fiat."},"amount_authorized":{"type":"string"},"amount_charged":{"type":"string"},"amount_refunded":{"type":"string"},"metering":{"type":"object","properties":{"basis":{"type":"string"},"units":{"type":"string"},"unit_price":{"type":"string"}},"required":["basis","units","unit_price"],"description":"Present only on a metered receipt. The three amounts above travel with it."},"sandbox":{"type":"boolean","enum":[true],"description":"Present, and always true, ONLY when the settlement was simulated."},"attempt_id":{"type":"string"},"money_moved":{"type":"boolean"},"at":{"type":"string"}},"required":["rail","provider","tx_id","amount_minor","amount_atomic","attempt_id","money_moved","at"]},"delivery":{"type":["object","null"],"properties":{"result":{"type":"string","enum":["confirmed","pending","failed"]},"proof":{"type":["string","null"]},"kind":{"type":["string","null"]},"nfe_chave":{"type":["string","null"]},"at":{"type":["string","null"]}},"required":["result","proof","kind","nfe_chave","at"],"description":"Null until a delivery proof has been sealed onto the receipt."},"chain":{"type":"string"},"receipt_sig":{"type":"string"},"exceptions":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string"},"detail":{"type":"string"},"at":{"type":"string"}},"required":["code","detail","at"]}}},"required":["receipt_id","state","mandate","quote","payment","delivery","chain","receipt_sig","exceptions"]}}}},"404":{"description":"No receipt with this id in this project. A receipt in another project of the same organization answers the same way.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["receipt_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/consumers/{consumerId}/contact-verifications":{"post":{"summary":"Send a one-time code to a consumer's e-mail address or phone number","description":"Proves possession of a contact, which is the pre-condition for registering an EMAIL or PHONE Pix key while that check is enforced on the deployment. Sends a code and returns the verification row. THE CODE IS NEVER IN THE RESPONSE, and never in a log line at info level: the target receives it, and nobody else.\n\n`target` is accepted in its natural spelling and canonicalized before anything else: an e-mail address is lower-cased, a phone number keeps only the digits behind its `+` and must be E.164 with a country code. A spelling that does not canonicalize answers 400 `invalid_target`.\n\nTHE LIMIT IS PER CONTACT, NOT PER CONSUMER: the count is over (organization, contact) in the last hour, so rotating `consumerId` buys no extra sends. Exceeding it answers 429 `too_many_requests`, with the window and the ceiling in `details`.\n\nA SEND SUPERSEDES THE LIVE ONE for the same contact: the previous verification is closed and only the new code counts. If that happens while this send is in flight, the 201 comes back with `expires_at` already in the past, and that is deliberate — two live codes would double the guessing budget.\n\nA `consumerId` the calling organization does not hold answers 422 `no_celcoin_account`, the same answer as an own consumer that has not finished onboarding. The route does not separate the two, so it cannot be used to probe for ids.","parameters":[{"schema":{"type":"string"},"required":true,"name":"consumerId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"channel":{"type":"string","enum":["email","phone"]},"target":{"type":"string","minLength":1,"maxLength":140},"locale":{"type":"string","minLength":2,"maxLength":16}},"required":["channel","target"]}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Send this back in the /verify step."},"channel":{"type":"string","enum":["email","phone"]},"target_masked":{"type":"string","description":"The masked contact. The full value never comes back: the row keeps a keyed hash."},"provider":{"type":"string","enum":["dev","prelude","resend"],"description":"Which sender delivered, and it decides the /verify step: `prelude` checks the code against the contact itself, so `target` is REQUIRED there. The other two are checked locally and do not need it."},"expires_at":{"type":"string","format":"date-time","description":"MAY ALREADY BE IN THE PAST. A second start for the same contact supersedes the earlier one by closing its validity, and if that lands while this send is in flight the row keeps the closed expiry and this field reports it. A superseded code answers 410 on verify."},"status":{"type":"string","enum":["sent"]}},"required":["id","channel","target_masked","provider","expires_at","status"]}}}},"400":{"description":"`invalid_body`: the body did not match the schema, and `details.issues` carries the validation errors. `invalid_target`: the contact does not canonicalize for this channel.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body","invalid_target"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"422":{"description":"`no_celcoin_account`: the consumer has no active pix-celcoin funding source in this organization — or is not this organization's consumer at all. `otp_sender_refused`: the sender refused to deliver to this number and repeating will not change that; `details.provider` names which sender said so. Nothing was sent, and no attempt was consumed, in either case.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["no_celcoin_account","otp_sender_refused"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"429":{"description":"`too_many_requests`: the per-contact hourly ceiling was reached. `details.window_seconds` and `details.max_sends` give the window and the ceiling. Nothing was sent.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["too_many_requests"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"500":{"description":"Two different bodies, because two different failures land here.\n\n`contact_verification_error` in the usual error envelope: an unclassified failure while starting. Nothing was sent that could be verified.\n\nTHE OTHER SHAPE IS NOT THAT ENVELOPE. Resolving the consumer's account runs before the handler's own error handling, so a failure there is answered by the framework's default: `{ statusCode, error, message }`, plus `code` when the underlying failure carried one. A client that parses `error.code` will find `error` is a STRING here. Nothing was sent in this case either.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["contact_verification_error"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]},{"type":"object","properties":{"statusCode":{"type":"number","enum":[500]},"error":{"type":"string","enum":["Internal Server Error"]},"message":{"type":"string"},"code":{"type":"string","description":"Present only when the underlying failure carried one."}},"required":["statusCode","error","message"]}]}}}},"503":{"description":"`otp_sender_unavailable`, and the three conditions behind it DO NOT agree on whether a retry is free.\n\n1. No sender is configured for this channel on the deployment. Nothing was written and nothing was sent; retrying changes nothing until the deployment does.\n\n2. The send itself did not complete. The verification row is removed, so the attempt does not count against the hourly ceiling and a retry is free. Removal is best effort: if it fails the row stands, unverifiable, and does consume one of the hour's sends.\n\n3. THE CODE REACHED THE CONTACT and the row could not be completed. The row STANDS and DOES count against the hourly ceiling, and the consumer may receive a code that can never be verified. `details.verification_id` is present, and it is present ONLY in this case — that is how a caller tells the free retry from the expensive one.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["otp_sender_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/consumers/{consumerId}/contact-verifications/{id}/verify":{"post":{"summary":"Check the code the consumer received","description":"Closes the proof of possession. `code` is the digits that arrived at the contact.\n\n`target` is REQUIRED when the send went out through a sender that checks the code against the contact itself — the `provider` the start step returned is `prelude`. That sender is keyed by the contact, and we hold only a keyed hash of it. Omitting `target` answers 400 `invalid_target`; sending a contact that does not hash to the row's answers 404, not 403.\n\nAN ATTEMPT IS RESERVED BEFORE THE CODE IS COMPARED, which is what makes the ceiling hold when guesses arrive concurrently — and it splits the failures in two.\n\nCOSTS NOTHING (raised before the reservation): a missing or malformed `target`; a `code` that is not 4 to 8 digits; and the 503 branch where no sender for this row's provider exists on the deployment.\n\nCOSTS AN ATTEMPT (from the reservation on): a wrong code, a `target` that does not hash to the row's, and the 503 branch where the provider did not answer. Wherever an attempt was spent the response carries `details.attempts_left`.\n\nIDEMPOTENT ON SUCCESS: a row that is already verified answers 200 again, with the original `verified_at` and without spending an attempt.\n\nAn `id` that is not a UUID answers 404 before any query runs, and a row belonging to another consumer or another organization answers the same 404.","parameters":[{"schema":{"type":"string"},"required":true,"name":"consumerId","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"code":{"type":"string","minLength":1,"maxLength":16},"target":{"type":"string","minLength":1,"maxLength":140}},"required":["code"]}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["verified"]},"verified_at":{"type":"string","format":"date-time"}},"required":["id","status","verified_at"]}}}},"400":{"description":"`invalid_body`: the body did not match the schema (`details.issues`). `invalid_code`: the code is not 4-8 digits, or it does not match; `details.attempts_left` says what is left, and only the second of those spent an attempt. `invalid_target`: this verification is checked against the contact and `target` did not come with the code.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body","invalid_code","invalid_target"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"`not_found`: there is no verification with this id for this consumer. It covers a malformed id, a row of another consumer, a row of another organization, a row whose send never completed, and a `target` that does not hash to the row's — that last one having spent an attempt.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"410":{"description":"`verification_expired`: the code expired, or a later send superseded it, or the sender itself reports it expired. Start a new verification.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["verification_expired"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"429":{"description":"`too_many_attempts`: this verification has spent every attempt. There is no reset; start a new one.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["too_many_attempts"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"500":{"description":"`contact_verification_error`: an unclassified failure while checking. This operation reads nothing outside its own error handling, so this is its only 500 body.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["contact_verification_error"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"503":{"description":"`otp_sender_unavailable` covers two conditions that cost different things.\n\nTHE PROVIDER DID NOT ANSWER the check. The attempt was reserved before the call, so it IS spent and is not given back — returning it would reopen the race the reservation closes. `details.attempts_left` says how many remain.\n\nNO SENDER FOR THIS ROW'S PROVIDER exists on this deployment. The verify goes back to whichever sender delivered, whatever the deployment is configured for today, and this check runs BEFORE the reservation: NO attempt was spent and the response carries no `details` at all. Nothing the caller does resolves it — start a new verification, which will be sent through a sender that exists.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["otp_sender_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/consumers/{consumerId}/dda/subscriptions":{"post":{"summary":"Register the consumer's CPF/CNPJ with DDA","description":"Registers the consumer's document with DDA, Brazil's national boleto-visibility register. The effect is broad and worth spelling out: from then on EVERY boleto issued against that document, by any issuer in the country, is pushed to us and mirrored. That is why the operation asks for two separate proofs.\n\nPROOF 1, OWNERSHIP. The document must be the consumer's VERIFIED document. The consumer entity answers when it carries one, and the funding source answers when it does not; if the two disagree the call is refused rather than settled by picking one. All three refusals answer `dda_document_not_owned`, and they are NOT one answer: the two 403s are word-for-word identical and cannot be told apart from outside, while an account with no verified document at all answers 422 and says so, because that one is fixed by finishing onboarding rather than by sending a different document. What none of the three ever does is name the document it expected — no refusal here is an oracle for anyone's CPF, and none of them carries `details`.\n\nPROOF 2, CONSENT. The document must be named in the SIGNED `dda_allowlist` of an active mandate of that consumer (`intent.dda_allowlist` at `POST /v1/consents/init`). There is no wildcard: a literal `\"*\"` in the list is normalized like any other entry and matches nothing. With no mandate authorizing it, 403, and nothing was registered.\n\n`idempotency_key` is REQUIRED. Together with the organization, the consumer and the document it derives the provider-side anchor, so re-sending the SAME key is the same registration and never a second one against a person's document — while the same key with a different document is a different registration. The HTTP status separates the two outcomes: 201 when this call registered, 200 when it did not.\n\nPunctuation in `document` is accepted and normalized; the comparison is on digits. A new registration is `pending` and turns `active` when the confirmation webhook arrives, so subscribe to `dda.subscription.activated` or re-read it rather than waiting for `active` here.\n\nSCOPE: DDA is per organization, not per project. The table carries no project column, so this route answers for the caller's whole organization.","parameters":[{"schema":{"type":"string"},"required":true,"name":"consumerId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"document":{"type":"string","minLength":1,"maxLength":40},"client_name":{"type":"string","minLength":1,"maxLength":140},"idempotency_key":{"type":"string","minLength":1,"maxLength":200}},"required":["document","idempotency_key"]}}}},"responses":{"200":{"description":"Nothing was registered by this call: an attempt under this same key already holds the registration, or one is in flight. `replayed` is true and `status` is the row's current state.","content":{"application/json":{"schema":{"type":"object","properties":{"document":{"type":"string","description":"Digits only. This is the document the consumer SIGNED in the mandate's allowlist, not the spelling that arrived in the body."},"status":{"type":"string","enum":["pending","active","error","deleted"],"description":"A registration this call made is always `pending`, and becomes `active` only when the provider's confirmation webhook lands. A 200 (nothing registered by this call) reports the row's current state, which can be any of the four."},"client_request_id":{"type":"string","description":"The provider-side anchor for this registration. Derived from (organization, consumer, document, `idempotency_key`), so the SAME key against a DIFFERENT document is a DIFFERENT registration."},"mandate_id":{"type":"string","description":"The active mandate whose signed allowlist authorized this."},"replayed":{"type":"boolean","description":"true when this call registered nothing. Either an earlier attempt under this key already holds the registration, or one is in flight right now."}},"required":["document","status","client_request_id","mandate_id","replayed"]}}}},"201":{"description":"This call registered the document. `replayed` is false and `status` is `pending`.","content":{"application/json":{"schema":{"type":"object","properties":{"document":{"type":"string","description":"Digits only. This is the document the consumer SIGNED in the mandate's allowlist, not the spelling that arrived in the body."},"status":{"type":"string","enum":["pending","active","error","deleted"],"description":"A registration this call made is always `pending`, and becomes `active` only when the provider's confirmation webhook lands. A 200 (nothing registered by this call) reports the row's current state, which can be any of the four."},"client_request_id":{"type":"string","description":"The provider-side anchor for this registration. Derived from (organization, consumer, document, `idempotency_key`), so the SAME key against a DIFFERENT document is a DIFFERENT registration."},"mandate_id":{"type":"string","description":"The active mandate whose signed allowlist authorized this."},"replayed":{"type":"boolean","description":"true when this call registered nothing. Either an earlier attempt under this key already holds the registration, or one is in flight right now."}},"required":["document","status","client_request_id","mandate_id","replayed"]}}}},"400":{"description":"`invalid_body`, from three places: the body did not match the schema (and only then is `details.issues` present); `document` carries no CPF/CNPJ digits; `idempotency_key` is blank. Nothing was registered.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"403":{"description":"`dda_document_not_owned`: the document is not the one verified for this consumer, or the consumer entity and the funding source disagree about who this consumer is. `dda_document_unauthorized`: no active mandate names this document in its signed allowlist. Nothing was registered, and neither carries `details`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["dda_document_not_owned","dda_document_unauthorized"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"422":{"description":"`no_celcoin_account`: the consumer has no active pix-celcoin funding source in this organization — or is not this organization's consumer at all. `dda_document_not_owned` ALSO answers 422, and only for one cause: the account has no verified document on file at all, so no document can be shown to be theirs. Sending a different document will not help; finish onboarding and retry.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["no_celcoin_account","dda_document_not_owned"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"500":{"description":"NOT the usual error envelope. Three reads — the consumer's account, the verified document, and the authorizing mandate — run before the handler's own error handling, so a failure in any of them is answered by the framework's default: `{ statusCode, error, message }`, plus `code` when the underlying failure carried one. All three sit before the provider is called, so nothing was registered.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["dda_provider_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]},{"type":"object","properties":{"statusCode":{"type":"number","enum":[500]},"error":{"type":"string","enum":["Internal Server Error"]},"message":{"type":"string"},"code":{"type":"string","description":"Present only when the underlying failure carried one."}},"required":["statusCode","error","message"]}]}}}},"502":{"description":"`dda_provider_unavailable`, from two conditions the response does not separate: the provider answered and the answer was not a success (the local row is marked `error`), or the call was in flight and whether it arrived is genuinely not known here. Because the caller cannot tell them apart, the guidance is the same for both — retry with the SAME `idempotency_key`, which makes the retry the same registration rather than a second one.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["dda_provider_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"503":{"description":"`dda_claim_unavailable`: the local claim failed and NOTHING was sent to the provider. This is stated apart from the 502 on purpose — here it is PROVEN that nothing left. Retry with the SAME `idempotency_key`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["dda_claim_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/mandates":{"get":{"summary":"List consumer allowances","description":"The allowances a consumer has signed, newest first by creation time. Scoped to the credential's organization by a predicate in the SELECT, so nothing in the query can widen it.\n\n`limit` caps the page at 200 and defaults to 50, and there is NO cursor: the response carries the rows and nothing to page with, so a consumer with more allowances than the limit is truncated in silence. Narrow with `consumer_id` and `status` rather than paging.\n\nA `status` this filter does not know is a 400, not an empty list. That matters because an unrecognised value answering `{ \"mandates\": [] }` reads as \"this consumer has none\" when it means \"you asked for a state that does not exist\".\n\nThe response object has one key, `mandates`, and no `count`.\n\n`GET /v1/consumers/mandates` is a live alias of this operation: same handler, same `mandates:read` scope.","parameters":[{"schema":{"type":"string","minLength":1},"required":false,"name":"consumer_id","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","enum":["active","paused","revoked","expired"]},"required":false,"name":"status","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"mandates":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"consumer_id":{"type":"string"},"agent_id":{"type":"string","description":"The agent this allowance authorizes to spend."},"display_name":{"type":["string","null"]},"purpose":{"type":"string"},"merchant_allowlist":{"type":"array","items":{"type":"string"},"description":"The payees this allowance may pay, read as `merchant_pin_kind` says to read them. Never empty: `consumer_mandates_allowlist_nonempty` refuses a row whose allowlist is not an array of at least one entry, so an allowance that authorizes nothing cannot exist."},"merchant_pin_kind":{"type":"string","enum":["pix-key","merchant-id","mcc"],"description":"How each allowlist entry pins a payee. The v1 rail pins by Pix key."},"intent_note":{"type":["string","null"]},"cap_minor":{"type":"string","description":"Total spend authorized over the life of the allowance, in minor units. A STRING: the column is `bigint` and the driver hands it back as text, so parse it as an integer and never as a float."},"per_tx_cap_minor":{"type":"string","description":"Per-transaction ceiling, in minor units. Same string shape as `cap_minor`."},"currency":{"type":"string","description":"Left open here rather than closed. The column admits BRL, USD, MXN, COP, ARS, USDC and BRLA today, and that list is a CHECK constraint on our side rather than a promise on the wire."},"status":{"type":"string","enum":["active","paused","revoked","expired"]},"expires_at":{"type":"string","format":"date-time","description":"Never null: the column is NOT NULL."},"created_at":{"type":"string","format":"date-time"}},"required":["id","consumer_id","agent_id","display_name","purpose","merchant_allowlist","merchant_pin_kind","intent_note","cap_minor","per_tx_cap_minor","currency","status","expires_at","created_at"]}}},"required":["mandates"]}}}},"400":{"description":"The query did not match the schema: an unknown `status`, a `limit` outside 1..200, or a `limit` that is not a number. `details.issues` carries the Zod issues.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_query"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/mandates/{id}":{"get":{"summary":"Read one consumer allowance","description":"The same 14-field projection the listing returns, by id, from the same table and under the same organization predicate.\n\nThis path has NO alias. Its four siblings are registered twice each, under `/v1/mandates/...` and `/v1/consumers/mandates/...`; this one is registered once, because point reads of an allowance did not exist before it and there was no older spelling to keep alive.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"consumer_id":{"type":"string"},"agent_id":{"type":"string","description":"The agent this allowance authorizes to spend."},"display_name":{"type":["string","null"]},"purpose":{"type":"string"},"merchant_allowlist":{"type":"array","items":{"type":"string"},"description":"The payees this allowance may pay, read as `merchant_pin_kind` says to read them. Never empty: `consumer_mandates_allowlist_nonempty` refuses a row whose allowlist is not an array of at least one entry, so an allowance that authorizes nothing cannot exist."},"merchant_pin_kind":{"type":"string","enum":["pix-key","merchant-id","mcc"],"description":"How each allowlist entry pins a payee. The v1 rail pins by Pix key."},"intent_note":{"type":["string","null"]},"cap_minor":{"type":"string","description":"Total spend authorized over the life of the allowance, in minor units. A STRING: the column is `bigint` and the driver hands it back as text, so parse it as an integer and never as a float."},"per_tx_cap_minor":{"type":"string","description":"Per-transaction ceiling, in minor units. Same string shape as `cap_minor`."},"currency":{"type":"string","description":"Left open here rather than closed. The column admits BRL, USD, MXN, COP, ARS, USDC and BRLA today, and that list is a CHECK constraint on our side rather than a promise on the wire."},"status":{"type":"string","enum":["active","paused","revoked","expired"]},"expires_at":{"type":"string","format":"date-time","description":"Never null: the column is NOT NULL."},"created_at":{"type":"string","format":"date-time"}},"required":["id","consumer_id","agent_id","display_name","purpose","merchant_allowlist","merchant_pin_kind","intent_note","cap_minor","per_tx_cap_minor","currency","status","expires_at","created_at"]}}}},"404":{"description":"No such allowance for this credential's organization. An allowance belonging to another organization answers exactly the same way: the org predicate sits in the SELECT, so the handler cannot tell absent from someone else's, and must not.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["mandate_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/mandates/{id}/pause":{"post":{"summary":"Pause an allowance","description":"Moves an `active` allowance to `paused`. `active` is the only legal source: a `paused`, `revoked` or `expired` allowance answers 409 rather than being quietly re-paused, and an allowance that is already `paused` answers 200 with `changed: false` and writes nothing.\n\nPausing stops the spend. The three places that spend against an allowance affirm the state they permit rather than listing the ones they refuse, so a status they do not recognise is refused rather than admitted.\n\nThe status flip and the evidence row are one transaction, and the UPDATE is guarded on the status this request read.\n\n`POST /v1/consumers/mandates/{id}/pause` is a live alias: same handler, same `mandates:write` scope.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string","maxLength":280,"description":"Recorded in the evidence row's metadata, alongside the from/to pair. Not echoed in the response."}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"mandate":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string","enum":["paused"]}},"required":["id","status"]},"changed":{"type":"boolean","description":"False when the allowance was already in this state. Nothing was written and no evidence row was appended."}},"required":["mandate","changed"]}}}},"400":{"description":"A body was sent and did not match the schema. `details.issues` carries the Zod issues.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"No such allowance for this credential's organization. An allowance belonging to another organization answers exactly the same way: the org predicate sits in the SELECT, so the handler cannot tell absent from someone else's, and must not.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["mandate_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"`invalid_transition` when the allowance's current status is not a legal source for this verb. `transition_conflict` when another request moved the allowance between this one's read and its write: the UPDATE is guarded on the status that was read, so a concurrent transition loses rather than double-applying, and the evidence row is written in the same transaction as the status change or not at all.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_transition","transition_conflict"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/mandates/{id}/resume":{"post":{"summary":"Resume a paused allowance","description":"Moves a `paused` allowance back to `active`. `paused` is the only legal source, so a revoked allowance answers 409 and does not come back to life.\n\nCaps and expiry are untouched: the UPDATE sets `status` and `updated_at` and nothing else, so the allowance resumes with the spend it had already used still counted against it. Resuming an allowance whose `expires_at` has passed is legal here and still buys nothing, because the spend path refuses an expired mandate on its own.\n\n`POST /v1/consumers/mandates/{id}/resume` is a live alias: same handler, same `mandates:write` scope.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string","maxLength":280,"description":"Recorded in the evidence row's metadata, alongside the from/to pair. Not echoed in the response."}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"mandate":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string","enum":["active"]}},"required":["id","status"]},"changed":{"type":"boolean","description":"False when the allowance was already in this state. Nothing was written and no evidence row was appended."}},"required":["mandate","changed"]}}}},"400":{"description":"A body was sent and did not match the schema. `details.issues` carries the Zod issues.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"No such allowance for this credential's organization. An allowance belonging to another organization answers exactly the same way: the org predicate sits in the SELECT, so the handler cannot tell absent from someone else's, and must not.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["mandate_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"`invalid_transition` when the allowance's current status is not a legal source for this verb. `transition_conflict` when another request moved the allowance between this one's read and its write: the UPDATE is guarded on the status that was read, so a concurrent transition loses rather than double-applying, and the evidence row is written in the same transaction as the status change or not at all.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_transition","transition_conflict"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/mandates/{id}/revoke":{"post":{"summary":"Revoke an allowance","description":"Moves an `active` or `paused` allowance to `revoked`, and there is no verb here that moves it back: `resume` accepts `paused` only. An `expired` allowance answers 409, because expiry is already terminal for spending and revoking it would only rewrite which terminal state the evidence log records.\n\nRevoking stops the spend on the same three gates that pausing does.\n\n`POST /v1/consumers/mandates/{id}/revoke` is a live alias: same handler, same `mandates:write` scope.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string","maxLength":280,"description":"Recorded in the evidence row's metadata, alongside the from/to pair. Not echoed in the response."}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"mandate":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string","enum":["revoked"]}},"required":["id","status"]},"changed":{"type":"boolean","description":"False when the allowance was already in this state. Nothing was written and no evidence row was appended."}},"required":["mandate","changed"]}}}},"400":{"description":"A body was sent and did not match the schema. `details.issues` carries the Zod issues.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"No such allowance for this credential's organization. An allowance belonging to another organization answers exactly the same way: the org predicate sits in the SELECT, so the handler cannot tell absent from someone else's, and must not.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["mandate_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"`invalid_transition` when the allowance's current status is not a legal source for this verb. `transition_conflict` when another request moved the allowance between this one's read and its write: the UPDATE is guarded on the status that was read, so a concurrent transition loses rather than double-applying, and the evidence row is written in the same transaction as the status change or not at all.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_transition","transition_conflict"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/ofb/consents":{"post":{"summary":"Initiate a bank consent","description":"Opens a consent at the bank and persists it as `pending`. `POST /v1/bank-consents` is a live alias and is the canonical spelling; this one is kept for clients that already call it.\n\nTHE BANK IS CALLED BEFORE THE ROW IS WRITTEN, and that ordering is visible in the failure modes below: every refusal that arrives after the bank call leaves a bank-side consent with no row of ours pointing at it. Retrying opens another one.\n\nOne open consent per consumer per bank per organization. A sibling project in your organization holding an open consent for this consumer at this bank is refused, and the refusal is not something a retry clears.\n\nOn 201 the consent is `pending`, and `authorisation_url`, `bank_consent_id` and `expires_at` are all set: the adapter's result declares them non-nullable and the INSERT writes all three. Send the consumer to `authorisation_url`, then post the code they come back with to the callback operation.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"bank_id":{"type":"string","minLength":1,"maxLength":64,"pattern":"^[a-z0-9-]+$"},"consumer_id":{"type":"string","minLength":1,"maxLength":128},"scopes":{"type":"array","items":{"type":"string","minLength":1,"maxLength":64},"minItems":1,"maxItems":20},"redirect_url":{"type":"string","format":"uri","description":"Where the bank returns the consumer after authorisation. You compose it and you host it."},"wallet_id":{"type":"string","minLength":1,"maxLength":128,"description":"Binds the consent to a wallet, so the statements it ingests carry that wallet."}},"required":["bank_id","consumer_id","scopes","redirect_url"]}}}},"responses":{"201":{"description":"Created. The consent is `pending` and `authorisation_url` is set.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"bank_id":{"type":"string","description":"Lowercase kebab-case, for example `itau` or `bb`."},"consumer_id":{"type":"string"},"wallet_id":{"type":["string","null"],"description":"Set when the consent was bound to a wallet at initiation; null when it was not."},"status":{"type":"string","enum":["pending","authorised","revoked","expired","consumed"]},"scopes":{"type":"array","items":{"type":"string"}},"bank_consent_id":{"type":["string","null"],"description":"The bank's own identifier for the consent. Null until the bank issues one."},"authorisation_url":{"type":["string","null"],"description":"Where the consumer authorises the consent. This is the URL to send them to."},"token_expires_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"authorized_at":{"type":["string","null"],"format":"date-time","description":"Spelled with a z, unlike `authorised` in `status`. Both spellings are column names, not typos."},"expires_at":{"type":["string","null"],"format":"date-time","description":"Mirrors the bank-side consent expiry, as the bank reported it at initiation."},"revoked_at":{"type":["string","null"],"format":"date-time"},"consumed_at":{"type":["string","null"],"format":"date-time"},"metadata":{"type":"object","additionalProperties":{}}},"required":["id","bank_id","consumer_id","wallet_id","status","scopes","bank_consent_id","authorisation_url","token_expires_at","created_at","authorized_at","expires_at","revoked_at","consumed_at","metadata"]}}}},"400":{"description":"The body did not match the schema. `details.issues` carries the Zod issues.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"`bank_consent_held_elsewhere` when another project in this organization holds an open (`pending` or `authorised`) consent for this consumer at this bank. `details.remediation` says what to do and `details.retriable` is false: one project's bank grant is never shared with another, so re-sending this request unchanged will not clear it.\n\n`consent_active_for_consumer` when YOUR OWN project already holds one. Revoke it first.\n\n`db_error` when the consent could not be persisted for any other reason. The cause is in our logs under the request id and is deliberately not in this body, because the driver's own message names another project's key values.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["bank_consent_held_elsewhere","consent_active_for_consumer","db_error"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"500":{"description":"`consent_disappeared`: the row was written and could not be read back. Do not retry blind; both the row and the bank-side consent may exist.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["consent_disappeared"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"502":{"description":"The bank refused the request or could not be reached, before anything was written. The message is the adapter's own.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["adapter_error"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"503":{"description":"`consent_lock_timeout`: another request is issuing a consent for this consumer at this bank and the wait ended without an answer, so NOTHING WAS DECIDED. We do not know whether a sibling holds the grant, and saying so is why this is not the 409. `details.retriable` is true. No consent row was created; a bank-side consent was, and is orphaned.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["consent_lock_timeout"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/ofb/consents/{id}/callback":{"post":{"summary":"Finalise a consent after the consumer authorises it","description":"Exchanges the bank's authorisation code for tokens and moves the consent to `authorised`. `POST /v1/bank-consents/{id}/callback` is a live alias and is the canonical spelling.\n\nOnly a `pending` consent can be finalised. A consent that is already `authorised` answers 409, not a second exchange, so replaying a callback does not mint a second token pair.\n\nThe token pointers and the `authorised` event are one transaction. On success the response is the re-read consent, without token material.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"auth_code":{"type":"string","minLength":1,"maxLength":2048,"description":"The code the bank put on the redirect."}},"required":["auth_code"]}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"bank_id":{"type":"string","description":"Lowercase kebab-case, for example `itau` or `bb`."},"consumer_id":{"type":"string"},"wallet_id":{"type":["string","null"],"description":"Set when the consent was bound to a wallet at initiation; null when it was not."},"status":{"type":"string","enum":["pending","authorised","revoked","expired","consumed"]},"scopes":{"type":"array","items":{"type":"string"}},"bank_consent_id":{"type":["string","null"],"description":"The bank's own identifier for the consent. Null until the bank issues one."},"authorisation_url":{"type":["string","null"],"description":"Where the consumer authorises the consent. This is the URL to send them to."},"token_expires_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"authorized_at":{"type":["string","null"],"format":"date-time","description":"Spelled with a z, unlike `authorised` in `status`. Both spellings are column names, not typos."},"expires_at":{"type":["string","null"],"format":"date-time","description":"Mirrors the bank-side consent expiry, as the bank reported it at initiation."},"revoked_at":{"type":["string","null"],"format":"date-time"},"consumed_at":{"type":["string","null"],"format":"date-time"},"metadata":{"type":"object","additionalProperties":{}}},"required":["id","bank_id","consumer_id","wallet_id","status","scopes","bank_consent_id","authorisation_url","token_expires_at","created_at","authorized_at","expires_at","revoked_at","consumed_at","metadata"]}}}},"400":{"description":"The body did not match the schema. `details.issues` carries the Zod issues.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"No such consent for this credential's organization AND project. A consent created by a sibling project in the same organization answers the same way: the resolver requires both predicates on purpose, because resolving on organization alone let one project read, transition and refresh another project's consent.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"The consent's current status does not permit this transition. `details.from` and `details.to` carry the pair. `pending` may become `authorised`, `revoked` or `expired`; `authorised` may become `revoked`, `expired` or `consumed`; `revoked`, `expired` and `consumed` are terminal and permit nothing.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["illegal_transition"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"500":{"description":"`missing_bank_consent_id`: the stored consent carries no bank-side id, so there is nothing to finalise. Ours to fix, not yours; quote the request id.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["missing_bank_consent_id"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"502":{"description":"The bank refused the code or could not be reached. What this handler wrote is nothing: the UPDATE runs only after the exchange returns, so the consent is still `pending` and no token pointer was stored. Whether the code itself is still usable is the BANK's state and not ours to report, so this answer does not claim either way. The message is the adapter's own, and empty when what the adapter threw was not an Error.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["adapter_error"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/ofb/consents/{id}/refresh-statement":{"post":{"summary":"Fetch the bank statement and ingest it","description":"Pulls transactions from the bank under an `authorised` consent and ingests them as events the reconciliation engine picks up. `POST /v1/bank-consents/{id}/refresh-statement` is a live alias and is the canonical spelling.\n\n`since` and `until` bound what is fetched. Both are optional; the adapter decides the window when they are absent.\n\nSAFE TO CALL TWICE. Each entry is ingested under a dedup key of (source, the bank's own event id, project), and an entry that key has already seen is counted in `duplicates` rather than credited again. That is what makes `imported` and `duplicates` mean what they say.\n\nThe answer is 202 rather than 200 because the statement is fetched and ingested synchronously but RECONCILIATION IS NOT: matching these events against the ledger happens on a later cycle.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"since":{"type":"string","format":"date-time"},"until":{"type":"string","format":"date-time"}}}}}},"responses":{"202":{"description":"Accepted. The statement was fetched and ingested; reconciliation happens after.","content":{"application/json":{"schema":{"type":"object","properties":{"consent_id":{"type":"string"},"run_id":{"type":"string","description":"Identifies this refresh in the consent's event log, on the started, completed and failed events alike. Quote it in support."},"imported":{"type":"integer","description":"Entries ingested as new events by this call."},"duplicates":{"type":"integer","description":"Entries a previous call had already ingested, recognised by the dedup key and not credited a second time."},"next_recon_cycle_within_seconds":{"type":"integer","description":"A constant hint about how often reconciliation runs. Not a queue estimate and not a promise about this run."}},"required":["consent_id","run_id","imported","duplicates","next_recon_cycle_within_seconds"]}}}},"400":{"description":"The body did not match the schema: `since` and `until` must be RFC 3339 timestamps carrying an offset. `details.issues` carries the Zod issues.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"No such consent for this credential's organization AND project. A consent created by a sibling project in the same organization answers the same way: the resolver requires both predicates on purpose, because resolving on organization alone let one project read, transition and refresh another project's consent.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"`consent_not_authorised`: the consent is not in `authorised`. `details.status` carries what it is.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["consent_not_authorised"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"500":{"description":"`consent_missing_tokens`: the consent says `authorised` but is missing something the fetch needs. TWO conditions raise it and the code does not distinguish them: no stored access-token pointer, OR no bank-side consent id. The second is not a token, so read the code as `this authorised consent is not usable` rather than as a statement about credentials. Ours to fix, not yours; quote the request id.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["consent_missing_tokens"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"502":{"description":"The bank refused or could not be reached. Partial progress is KEPT: each entry is committed as it is ingested, so entries taken before the failure stay ingested and a retry re-imports only what is missing. The message is the adapter's own unless the failure came from our own database, in which case it is replaced by a fixed sentence rather than naming our tables.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["adapter_error"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/ofb/consents/{id}/revoke":{"post":{"summary":"Revoke a bank consent","description":"Moves a `pending` or `authorised` consent to `revoked`. `POST /v1/bank-consents/{id}/revoke` is a live alias and is the canonical spelling.\n\nTHIS STOPS US, NOT THE BANK. The handler calls no bank adapter: it flips our row and appends a `revoked` event, so what ends is our use of the consent. If the grant must also end at the bank, revoke it in the bank's own channel as well.\n\nRevoking frees the (organization, consumer, bank) slot, so a new consent can be initiated afterwards. `revoked` is terminal: a second revoke answers 409.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string","minLength":1,"maxLength":512,"description":"Recorded on the `revoked` event."}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"bank_id":{"type":"string","description":"Lowercase kebab-case, for example `itau` or `bb`."},"consumer_id":{"type":"string"},"wallet_id":{"type":["string","null"],"description":"Set when the consent was bound to a wallet at initiation; null when it was not."},"status":{"type":"string","enum":["pending","authorised","revoked","expired","consumed"]},"scopes":{"type":"array","items":{"type":"string"}},"bank_consent_id":{"type":["string","null"],"description":"The bank's own identifier for the consent. Null until the bank issues one."},"authorisation_url":{"type":["string","null"],"description":"Where the consumer authorises the consent. This is the URL to send them to."},"token_expires_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"authorized_at":{"type":["string","null"],"format":"date-time","description":"Spelled with a z, unlike `authorised` in `status`. Both spellings are column names, not typos."},"expires_at":{"type":["string","null"],"format":"date-time","description":"Mirrors the bank-side consent expiry, as the bank reported it at initiation."},"revoked_at":{"type":["string","null"],"format":"date-time"},"consumed_at":{"type":["string","null"],"format":"date-time"},"metadata":{"type":"object","additionalProperties":{}}},"required":["id","bank_id","consumer_id","wallet_id","status","scopes","bank_consent_id","authorisation_url","token_expires_at","created_at","authorized_at","expires_at","revoked_at","consumed_at","metadata"]}}}},"400":{"description":"The body did not match the schema. `details.issues` carries the Zod issues.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"No such consent for this credential's organization AND project. A consent created by a sibling project in the same organization answers the same way: the resolver requires both predicates on purpose, because resolving on organization alone let one project read, transition and refresh another project's consent.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"The consent's current status does not permit this transition. `details.from` and `details.to` carry the pair. `pending` may become `authorised`, `revoked` or `expired`; `authorised` may become `revoked`, `expired` or `consumed`; `revoked`, `expired` and `consumed` are terminal and permit nothing.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["illegal_transition"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/mcp-servers/validate":{"post":{"summary":"Connect to an upstream MCP server and list its tools","description":"A dry run before create: CodeSpar opens a StreamableHTTP MCP session to `upstream_url` and returns its `tools/list`, so a seller can see what will be priced. Read only, and nothing is stored. `auth`, when given, is sent as a single request header to the upstream and is not persisted by this call.\n\nAN UNREACHABLE UPSTREAM IS A 200, NOT AN ERROR. Connection and listing failures are reported in the body as `valid: false` with `error` and an empty `tools`, because the request itself succeeded. Only a malformed body or a rejected URL produce a 400.\n\n`upstream_url` is checked before any connection is opened, against the address it RESOLVES to and not only its text, so a hostname the caller controls cannot be pointed at a private address. A rejected URL comes back as `invalid_upstream_url` with `details.reason` set to `url_not_public_http`, `host_unresolvable` or `host_resolves_to_blocked_address`.\n\nBoth the connect and the tool listing are bounded at eight seconds.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"upstream_url":{"type":"string","maxLength":2048,"format":"uri"},"auth":{"type":"object","properties":{"header":{"type":"string","minLength":1,"maxLength":128},"value":{"type":"string","minLength":1,"maxLength":4096}},"required":["header","value"]}},"required":["upstream_url"]}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"valid":{"type":"boolean"},"count":{"type":"integer","description":"The length of `tools`."},"tools":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string","description":"Empty string when the upstream sent none, or sent a non-string."},"input_schema":{"description":"The upstream's `inputSchema`, or `{}` when it sent none."}},"required":["name","description"]},"description":"Empty when `valid` is false."},"error":{"type":"string","description":"Present only when `valid` is false. `invalid_url`, `connect_timeout` and `list_tools_timeout` come from the introspection itself; anything else is the upstream error's own message, so treat this as an open string rather than a code enum."}},"required":["valid","count","tools"]}}}},"400":{"description":"The body did not match the schema (`invalid_body`, with `details.issues` carrying the validation issues), or the URL was refused by the address guard (`invalid_upstream_url`, with `details.upstream_url` and `details.reason`).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body","invalid_upstream_url"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"403":{"description":"Forbidden. The operation requires the `admin` role in the organization, resolved from the acting user forwarded in `x-codespar-user`. Known values of `error`: `insufficient_role`, `bearer_admin_role_missing`, `bearer_admin_user_not_member`, `bearer_admin_role_unresolved`. The same status and body shape also carry the scope gate's refusals, which reach every route in this subtree including the two reads: `forbidden` when the credential lacks the scope the route is mapped to, and the two alarms `scopes_unresolved` and `route_scope_unresolved`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"required":{"type":"string","description":"The minimum role, `admin` for every gated operation here."},"message":{"type":"string"},"status":{"type":"number","enum":[403]}},"required":["error"]}}}}}}},"/v1/mcp-servers":{"post":{"summary":"Register a monetized MCP server and price its tools","description":"Creates the server, provisions the wallet that will receive its revenue, and writes one priced row per entry in `tools`. At least one tool is required and at most 200. Each `price` is a positive USDC decimal string with up to six decimals; a value outside that shape is refused with `invalid_body` rather than rounded.\n\nWHERE THE MONEY LANDS. `consumer_id` selects the provisioned CDP wallet that receives settlement. The address is derived for that value, and created if it does not exist yet, so a mistyped `consumer_id` produces a working server pointing at a DIFFERENT wallet rather than a validation error. Bring-your-own addresses are not offered here.\n\n`platform_fee_bps` is stored on the row and applied later by settlement. It is not charged by this call and changing it is not possible through the patch operation.\n\n`upstream_auth`, when given, is encrypted into the vault and never returned by any read on this resource.\n\nTWO GOTCHAS ON THE 400s. A slug already taken by ANY organization returns `slug_conflict` with status 400, not 409, because the slug namespace is global. And that same code is what a `tools` array repeating a `tool_name` produces: the handler classifies a unique-constraint violation by its SQL state alone and cannot tell the two constraints apart, so read `slug_conflict` as `a uniqueness constraint was violated` and check both.\n\n503 `payto_provisioning_unavailable` covers every failure of the wallet step, including a project with no Coinbase CDP credential connected. Nothing is written when it is returned.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"slug":{"type":"string","minLength":1,"maxLength":64,"pattern":"^[a-z0-9_-]+$"},"name":{"type":"string","minLength":1,"maxLength":128},"description":{"type":"string","maxLength":300},"overview_md":{"type":"string","maxLength":20000},"category":{"type":"string","maxLength":64},"upstream_url":{"type":"string","maxLength":2048,"format":"uri"},"consumer_id":{"type":"string","minLength":1,"maxLength":128},"upstream_auth":{"type":"object","properties":{"header":{"type":"string","minLength":1,"maxLength":128},"value":{"type":"string","minLength":1,"maxLength":4096}},"required":["header","value"]},"platform_fee_bps":{"type":"integer","minimum":0,"maximum":10000},"tools":{"type":"array","items":{"type":"object","properties":{"tool_name":{"type":"string","minLength":1,"maxLength":128},"description":{"type":"string","maxLength":2048},"input_schema":{},"price":{"type":"string","minLength":1,"maxLength":32}},"required":["tool_name","price"]},"minItems":1,"maxItems":200}},"required":["slug","name","upstream_url","consumer_id","tools"]}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`mcs_` followed by a nanoid."},"project_id":{"type":"string"},"slug":{"type":"string","description":"Lowercase letters, digits, underscore or dash, up to 64 characters. The slug namespace is GLOBAL across all tenants, so a slug another organization already holds is refused."},"name":{"type":"string"},"description":{"type":"string","description":"Listing card summary. Empty string when none was supplied."},"overview_md":{"type":"string","description":"Listing page body, markdown. Empty string when none was supplied."},"category":{"type":["string","null"]},"upstream_url":{"type":"string"},"environment":{"type":"string","enum":["live","test"],"description":"Taken from the calling credential's environment at create time. There is no body field for it."},"payto_kind":{"type":"string","enum":["provisioned","split"],"description":"Create always writes `provisioned`, a CodeSpar-derived CDP wallet. `split` exists in the column but no operation on this surface produces it, and bring-your-own is not offered."},"payto_address":{"type":"string","description":"The address that receives USDC for calls settled through the gateway."},"consumer_id":{"type":["string","null"]},"platform_fee_bps":{"type":"integer","description":"Basis points of each settled call accrued as a platform fee, 0 to 10000. Stored at create and applied by settlement, not by this resource."},"tool_count":{"type":"integer","description":"The number of tools supplied at create time. Nothing recomputes it, so deactivating a tool leaves it unchanged and it is not a count of ACTIVE tools."},"active":{"type":"boolean"},"gateway_url":{"type":"string","description":"`https://gw.codespar.dev/mcp/<slug>`, rebuilt from the slug on every read rather than stored."},"tools":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"`mct_` followed by a nanoid."},"tool_name":{"type":"string","description":"The MCP tool name. Unique within one server, and the key the tool patch addresses."},"description":{"type":"string","description":"Empty string when none was supplied."},"input_schema":{"description":"The tool's argument JSON Schema, stored as it was received from the upstream's `tools/list` or from the create body. `{}` when none was supplied."},"pricing_model":{"type":"string","description":"Only `flat` is written today. The column is a free string with no CHECK constraint, so read it as open rather than as a closed enum."},"price":{"type":"string","description":"The per-call price rendered as a trimmed USDC decimal string, derived from `price_atomic`."},"price_atomic":{"type":"string","description":"The authoritative per-call price, in USDC atomic units (six decimals), as an integer string. `price` is a rendering of this value; charge decisions should read this one."},"active":{"type":"boolean","description":"An inactive tool stays listed and stays priced."}},"required":["id","tool_name","description","pricing_model","price","price_atomic","active"]},"description":"Present on create, on the single read, and on the listing patch. ABSENT from the list response, which returns servers without their tools."},"created_at":{"type":"string","format":"date-time"}},"required":["id","project_id","slug","name","description","overview_md","category","upstream_url","environment","payto_kind","payto_address","consumer_id","platform_fee_bps","tool_count","active","gateway_url","created_at"]}}}},"400":{"description":"`invalid_body` for a body that failed the schema (`details.issues`) or a tool price that is not a positive six-decimal USDC amount; `invalid_upstream_url` for a URL refused by the address guard; `slug_conflict` for a duplicate slug or a repeated tool name.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body","invalid_upstream_url","slug_conflict"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"403":{"description":"Forbidden. The operation requires the `admin` role in the organization, resolved from the acting user forwarded in `x-codespar-user`. Known values of `error`: `insufficient_role`, `bearer_admin_role_missing`, `bearer_admin_user_not_member`, `bearer_admin_role_unresolved`. The same status and body shape also carry the scope gate's refusals, which reach every route in this subtree including the two reads: `forbidden` when the credential lacks the scope the route is mapped to, and the two alarms `scopes_unresolved` and `route_scope_unresolved`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"required":{"type":"string","description":"The minimum role, `admin` for every gated operation here."},"message":{"type":"string"},"status":{"type":"number","enum":[403]}},"required":["error"]}}}},"503":{"description":"The receiving CDP address could not be provisioned. The server was not created; retry after connecting Coinbase CDP for this project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["payto_provisioning_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"get":{"summary":"List this project's MCP servers","description":"Every server in the calling credential's organization AND project, newest first. There is no pagination and no filter: the whole set comes back in one response.\n\nThe rows carry no `tools` array. Read one server to get its priced tools.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"mcp_servers":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"`mcs_` followed by a nanoid."},"project_id":{"type":"string"},"slug":{"type":"string","description":"Lowercase letters, digits, underscore or dash, up to 64 characters. The slug namespace is GLOBAL across all tenants, so a slug another organization already holds is refused."},"name":{"type":"string"},"description":{"type":"string","description":"Listing card summary. Empty string when none was supplied."},"overview_md":{"type":"string","description":"Listing page body, markdown. Empty string when none was supplied."},"category":{"type":["string","null"]},"upstream_url":{"type":"string"},"environment":{"type":"string","enum":["live","test"],"description":"Taken from the calling credential's environment at create time. There is no body field for it."},"payto_kind":{"type":"string","enum":["provisioned","split"],"description":"Create always writes `provisioned`, a CodeSpar-derived CDP wallet. `split` exists in the column but no operation on this surface produces it, and bring-your-own is not offered."},"payto_address":{"type":"string","description":"The address that receives USDC for calls settled through the gateway."},"consumer_id":{"type":["string","null"]},"platform_fee_bps":{"type":"integer","description":"Basis points of each settled call accrued as a platform fee, 0 to 10000. Stored at create and applied by settlement, not by this resource."},"tool_count":{"type":"integer","description":"The number of tools supplied at create time. Nothing recomputes it, so deactivating a tool leaves it unchanged and it is not a count of ACTIVE tools."},"active":{"type":"boolean"},"gateway_url":{"type":"string","description":"`https://gw.codespar.dev/mcp/<slug>`, rebuilt from the slug on every read rather than stored."},"tools":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"`mct_` followed by a nanoid."},"tool_name":{"type":"string","description":"The MCP tool name. Unique within one server, and the key the tool patch addresses."},"description":{"type":"string","description":"Empty string when none was supplied."},"input_schema":{"description":"The tool's argument JSON Schema, stored as it was received from the upstream's `tools/list` or from the create body. `{}` when none was supplied."},"pricing_model":{"type":"string","description":"Only `flat` is written today. The column is a free string with no CHECK constraint, so read it as open rather than as a closed enum."},"price":{"type":"string","description":"The per-call price rendered as a trimmed USDC decimal string, derived from `price_atomic`."},"price_atomic":{"type":"string","description":"The authoritative per-call price, in USDC atomic units (six decimals), as an integer string. `price` is a rendering of this value; charge decisions should read this one."},"active":{"type":"boolean","description":"An inactive tool stays listed and stays priced."}},"required":["id","tool_name","description","pricing_model","price","price_atomic","active"]},"description":"Present on create, on the single read, and on the listing patch. ABSENT from the list response, which returns servers without their tools."},"created_at":{"type":"string","format":"date-time"}},"required":["id","project_id","slug","name","description","overview_md","category","upstream_url","environment","payto_kind","payto_address","consumer_id","platform_fee_bps","tool_count","active","gateway_url","created_at"]}}},"required":["mcp_servers"]}}}}}}},"/v1/mcp-servers/{id}":{"get":{"summary":"Read one MCP server with its priced tools","description":"The server plus every tool row, ordered by `tool_name`, active and inactive alike.\n\nAn id belonging to another organization or another project returns 404, not 403. The lookup carries both predicates in its WHERE clause, so the handler cannot distinguish `someone else owns this` from `no such row`, and must not, because telling them apart would confirm the id exists.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`mcs_` followed by a nanoid."},"project_id":{"type":"string"},"slug":{"type":"string","description":"Lowercase letters, digits, underscore or dash, up to 64 characters. The slug namespace is GLOBAL across all tenants, so a slug another organization already holds is refused."},"name":{"type":"string"},"description":{"type":"string","description":"Listing card summary. Empty string when none was supplied."},"overview_md":{"type":"string","description":"Listing page body, markdown. Empty string when none was supplied."},"category":{"type":["string","null"]},"upstream_url":{"type":"string"},"environment":{"type":"string","enum":["live","test"],"description":"Taken from the calling credential's environment at create time. There is no body field for it."},"payto_kind":{"type":"string","enum":["provisioned","split"],"description":"Create always writes `provisioned`, a CodeSpar-derived CDP wallet. `split` exists in the column but no operation on this surface produces it, and bring-your-own is not offered."},"payto_address":{"type":"string","description":"The address that receives USDC for calls settled through the gateway."},"consumer_id":{"type":["string","null"]},"platform_fee_bps":{"type":"integer","description":"Basis points of each settled call accrued as a platform fee, 0 to 10000. Stored at create and applied by settlement, not by this resource."},"tool_count":{"type":"integer","description":"The number of tools supplied at create time. Nothing recomputes it, so deactivating a tool leaves it unchanged and it is not a count of ACTIVE tools."},"active":{"type":"boolean"},"gateway_url":{"type":"string","description":"`https://gw.codespar.dev/mcp/<slug>`, rebuilt from the slug on every read rather than stored."},"tools":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"`mct_` followed by a nanoid."},"tool_name":{"type":"string","description":"The MCP tool name. Unique within one server, and the key the tool patch addresses."},"description":{"type":"string","description":"Empty string when none was supplied."},"input_schema":{"description":"The tool's argument JSON Schema, stored as it was received from the upstream's `tools/list` or from the create body. `{}` when none was supplied."},"pricing_model":{"type":"string","description":"Only `flat` is written today. The column is a free string with no CHECK constraint, so read it as open rather than as a closed enum."},"price":{"type":"string","description":"The per-call price rendered as a trimmed USDC decimal string, derived from `price_atomic`."},"price_atomic":{"type":"string","description":"The authoritative per-call price, in USDC atomic units (six decimals), as an integer string. `price` is a rendering of this value; charge decisions should read this one."},"active":{"type":"boolean","description":"An inactive tool stays listed and stays priced."}},"required":["id","tool_name","description","pricing_model","price","price_atomic","active"]},"description":"Present on create, on the single read, and on the listing patch. ABSENT from the list response, which returns servers without their tools."},"created_at":{"type":"string","format":"date-time"}},"required":["id","project_id","slug","name","description","overview_md","category","upstream_url","environment","payto_kind","payto_address","consumer_id","platform_fee_bps","tool_count","active","gateway_url","created_at"]}}}},"404":{"description":"No such server in this organization and project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["mcp_server_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"patch":{"summary":"Update an MCP server's listing metadata or its active flag","description":"Accepts `name`, `description`, `overview_md`, `category` and `active`. At least one must be present; an empty object is refused with `invalid_body`.\n\nNOTHING ABOUT MONEY OR ROUTING IS PATCHABLE HERE. `slug`, `upstream_url`, `upstream_auth`, `consumer_id`, `payto_address` and `platform_fee_bps` have no field on this operation, and tool prices move through the tool patch below.\n\nOmitted fields keep their stored value. `category` therefore cannot be CLEARED through this operation: the update coalesces an absent field onto the existing one and the schema does not accept null. `description` and `overview_md` can be emptied by sending `\"\"`.\n\n`active: false` is what takes the gateway URL out of service; it is not a delete and the slug stays held.\n\nReturns the updated server with its tools. Cross-tenant ids return 404, for the same reason as the read.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":128},"description":{"type":"string","maxLength":300},"overview_md":{"type":"string","maxLength":20000},"category":{"type":"string","maxLength":64},"active":{"type":"boolean"}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`mcs_` followed by a nanoid."},"project_id":{"type":"string"},"slug":{"type":"string","description":"Lowercase letters, digits, underscore or dash, up to 64 characters. The slug namespace is GLOBAL across all tenants, so a slug another organization already holds is refused."},"name":{"type":"string"},"description":{"type":"string","description":"Listing card summary. Empty string when none was supplied."},"overview_md":{"type":"string","description":"Listing page body, markdown. Empty string when none was supplied."},"category":{"type":["string","null"]},"upstream_url":{"type":"string"},"environment":{"type":"string","enum":["live","test"],"description":"Taken from the calling credential's environment at create time. There is no body field for it."},"payto_kind":{"type":"string","enum":["provisioned","split"],"description":"Create always writes `provisioned`, a CodeSpar-derived CDP wallet. `split` exists in the column but no operation on this surface produces it, and bring-your-own is not offered."},"payto_address":{"type":"string","description":"The address that receives USDC for calls settled through the gateway."},"consumer_id":{"type":["string","null"]},"platform_fee_bps":{"type":"integer","description":"Basis points of each settled call accrued as a platform fee, 0 to 10000. Stored at create and applied by settlement, not by this resource."},"tool_count":{"type":"integer","description":"The number of tools supplied at create time. Nothing recomputes it, so deactivating a tool leaves it unchanged and it is not a count of ACTIVE tools."},"active":{"type":"boolean"},"gateway_url":{"type":"string","description":"`https://gw.codespar.dev/mcp/<slug>`, rebuilt from the slug on every read rather than stored."},"tools":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"`mct_` followed by a nanoid."},"tool_name":{"type":"string","description":"The MCP tool name. Unique within one server, and the key the tool patch addresses."},"description":{"type":"string","description":"Empty string when none was supplied."},"input_schema":{"description":"The tool's argument JSON Schema, stored as it was received from the upstream's `tools/list` or from the create body. `{}` when none was supplied."},"pricing_model":{"type":"string","description":"Only `flat` is written today. The column is a free string with no CHECK constraint, so read it as open rather than as a closed enum."},"price":{"type":"string","description":"The per-call price rendered as a trimmed USDC decimal string, derived from `price_atomic`."},"price_atomic":{"type":"string","description":"The authoritative per-call price, in USDC atomic units (six decimals), as an integer string. `price` is a rendering of this value; charge decisions should read this one."},"active":{"type":"boolean","description":"An inactive tool stays listed and stays priced."}},"required":["id","tool_name","description","pricing_model","price","price_atomic","active"]},"description":"Present on create, on the single read, and on the listing patch. ABSENT from the list response, which returns servers without their tools."},"created_at":{"type":"string","format":"date-time"}},"required":["id","project_id","slug","name","description","overview_md","category","upstream_url","environment","payto_kind","payto_address","consumer_id","platform_fee_bps","tool_count","active","gateway_url","created_at"]}}}},"400":{"description":"The body failed the schema, or was an empty patch. `details.issues` carries the issues.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"403":{"description":"Forbidden. The operation requires the `admin` role in the organization, resolved from the acting user forwarded in `x-codespar-user`. Known values of `error`: `insufficient_role`, `bearer_admin_role_missing`, `bearer_admin_user_not_member`, `bearer_admin_role_unresolved`. The same status and body shape also carry the scope gate's refusals, which reach every route in this subtree including the two reads: `forbidden` when the credential lacks the scope the route is mapped to, and the two alarms `scopes_unresolved` and `route_scope_unresolved`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"required":{"type":"string","description":"The minimum role, `admin` for every gated operation here."},"message":{"type":"string"},"status":{"type":"number","enum":[403]}},"required":["error"]}}}},"404":{"description":"No such server in this organization and project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["mcp_server_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"delete":{"summary":"Delete an MCP server and its priced tools","description":"A HARD delete, not a deactivation: the row is removed and its tool rows cascade with it. There is no undo and no restore, and the gateway URL stops resolving. Use `PATCH /v1/mcp-servers/{id}` with `active: false` to take a server out of service while keeping the row and the slug.\n\nThe slug returns to the global namespace and can be claimed by any organization afterwards.\n\nThe stored upstream credential is not deleted by this call; only the server and its tools are. Returns 204 with no body, or 404 when no row in this organization and project matched.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"No Content"},"403":{"description":"Forbidden. The operation requires the `admin` role in the organization, resolved from the acting user forwarded in `x-codespar-user`. Known values of `error`: `insufficient_role`, `bearer_admin_role_missing`, `bearer_admin_user_not_member`, `bearer_admin_role_unresolved`. The same status and body shape also carry the scope gate's refusals, which reach every route in this subtree including the two reads: `forbidden` when the credential lacks the scope the route is mapped to, and the two alarms `scopes_unresolved` and `route_scope_unresolved`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"required":{"type":"string","description":"The minimum role, `admin` for every gated operation here."},"message":{"type":"string"},"status":{"type":"number","enum":[403]}},"required":["error"]}}}},"404":{"description":"No such server in this organization and project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["mcp_server_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/mcp-servers/{id}/tools/{tool}":{"patch":{"summary":"Reprice one tool, or turn it off","description":"Accepts `price` and `active`; at least one must be present. `{tool}` is the TOOL NAME, not an id, so it must be URL-encoded when the name contains a character that would otherwise end the path segment.\n\nA MONEY EDIT THAT TAKES EFFECT ON THE NEXT CALL. `price` is a positive USDC decimal string with up to six decimals and replaces the tool's price outright; there is no schedule and no grace period. A price outside that shape is refused with `invalid_body` rather than rounded to something chargeable.\n\nReturns the updated tool alone, not the whole server. `tool_count` on the server is unaffected by `active: false`.\n\n404 `mcp_tool_not_found` is the single answer for three different situations: no such tool on that server, no such server, and a server owned by another organization or project. The update is scoped through the parent server in one statement, so the handler sees only `no row matched`.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"tool","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"price":{"type":"string","minLength":1,"maxLength":32},"active":{"type":"boolean"}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`mct_` followed by a nanoid."},"tool_name":{"type":"string","description":"The MCP tool name. Unique within one server, and the key the tool patch addresses."},"description":{"type":"string","description":"Empty string when none was supplied."},"input_schema":{"description":"The tool's argument JSON Schema, stored as it was received from the upstream's `tools/list` or from the create body. `{}` when none was supplied."},"pricing_model":{"type":"string","description":"Only `flat` is written today. The column is a free string with no CHECK constraint, so read it as open rather than as a closed enum."},"price":{"type":"string","description":"The per-call price rendered as a trimmed USDC decimal string, derived from `price_atomic`."},"price_atomic":{"type":"string","description":"The authoritative per-call price, in USDC atomic units (six decimals), as an integer string. `price` is a rendering of this value; charge decisions should read this one."},"active":{"type":"boolean","description":"An inactive tool stays listed and stays priced."}},"required":["id","tool_name","description","pricing_model","price","price_atomic","active"]}}}},"400":{"description":"The body failed the schema, was an empty patch, or carried a `price` that is not a positive six-decimal USDC amount.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"403":{"description":"Forbidden. The operation requires the `admin` role in the organization, resolved from the acting user forwarded in `x-codespar-user`. Known values of `error`: `insufficient_role`, `bearer_admin_role_missing`, `bearer_admin_user_not_member`, `bearer_admin_role_unresolved`. The same status and body shape also carry the scope gate's refusals, which reach every route in this subtree including the two reads: `forbidden` when the credential lacks the scope the route is mapped to, and the two alarms `scopes_unresolved` and `route_scope_unresolved`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"required":{"type":"string","description":"The minimum role, `admin` for every gated operation here."},"message":{"type":"string"},"status":{"type":"number","enum":[403]}},"required":["error"]}}}},"404":{"description":"No tool by that name on a server this organization and project owns.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["mcp_tool_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/mcp-servers/platform-fees/sweep":{"post":{"summary":"Move accrued platform fees to the organization's wallet now","description":"Settled tool calls on a server with `platform_fee_bps` above zero accrue a fee owed to the platform partner. This moves everything unswept for the calling organization to its provisioned platform wallet and marks those accruals swept. The body takes no fields.\n\nONE TRANSFER PER (ENVIRONMENT, SOURCE ACCOUNT) PAIR, NOT PER ACCOUNT. Accruals are grouped by the seller's source account AND the environment they were earned in, and each group is one on-chain transfer: `live` settles on Base, `test` on Base Sepolia. The source account name is derived from the organization and the seller's `consumer_id` and does not carry the environment, so ONE seller selling in both environments produces TWO transfers that repeat the same `source` in `transfers`, on two different chains. Do not read `transfers` as one row per seller, and do not sum a `source` expecting a single chain's movement.\n\nA BACKGROUND JOB ALREADY DOES THIS, hourly by default. This endpoint is the manual trigger over the same core; it is not the only way fees move, so a zero result usually means the job got there first.\n\nNOT IDEMPOTENT. Each call executes real transfers, so it is not safe to retry blindly. Concurrency is the one case that is handled for you: a per-organization lock is held for the pass, and a second caller arriving while one runs gets 409 `sweep_in_progress` and moves no money. A call that times out on the client side may still have transferred; re-read the result of the next sweep rather than repeating this one.\n\nPARTIAL SUCCESS IS A 200. A group whose transfer failed appears in `transfers` with `status: \"failed\"` and `tx: null`, its accruals are left unswept for the next pass, and the response is still 200. `swept` and `total_atomic` count the successful transfers only, so compare them against `transfers` rather than assuming everything moved. `total_atomic` sums across environments and is therefore not a single chain's amount.\n\nWith nothing to sweep the response is `swept: 0`, `total_atomic: \"0\"`, an empty `transfers`, and no `partner_wallet`.\n\n503 `payto_provisioning_unavailable` is broader than its name: the handler maps EVERY error thrown by the pass to that one code, so it covers a database failure during the sweep as well as a platform wallet that could not be provisioned.\n\nThis operation needs the `platform-fees:sweep` scope, which the other seven do not.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"swept":{"type":"integer","description":"How many TRANSFERS succeeded. It is not a count of accrual rows or of servers."},"total_atomic":{"type":"string","description":"The sum of the successful transfers only, in USDC atomic units. It sums across environments, so it is not an amount that moved on any single chain."},"partner_wallet":{"type":"string","description":"Absent when there was nothing to sweep, because no address is resolved in that case."},"transfers":{"type":"array","items":{"type":"object","properties":{"source":{"type":"string","description":"The CDP account name the fees were transferred FROM. It is derived from the organization and the seller's `consumer_id` and carries NO environment, so two entries in one response can repeat the same `source` — one per environment that had accruals. There is no `environment` field here to tell them apart; `amount_atomic` and `tx` are what distinguish the two."},"amount_atomic":{"type":"string","description":"The exact accrued fee moved in this transfer, in USDC atomic units, as an integer string. It is the sum of the accruals of ONE (environment, source account) pair, not of the source account across environments."},"tx":{"type":["string","null"],"description":"The provider transaction id, and null when `status` is `failed`."},"status":{"type":"string","enum":["swept","failed"]}},"required":["source","amount_atomic","tx","status"]}}},"required":["swept","total_atomic","transfers"]}}}},"403":{"description":"Forbidden. The operation requires the `admin` role in the organization, resolved from the acting user forwarded in `x-codespar-user`. Known values of `error`: `insufficient_role`, `bearer_admin_role_missing`, `bearer_admin_user_not_member`, `bearer_admin_role_unresolved`. The same status and body shape also carry the scope gate's refusals, which reach every route in this subtree including the two reads: `forbidden` when the credential lacks the scope the route is mapped to, and the two alarms `scopes_unresolved` and `route_scope_unresolved`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"required":{"type":"string","description":"The minimum role, `admin` for every gated operation here."},"message":{"type":"string"},"status":{"type":"number","enum":[403]}},"required":["error"]}}}},"409":{"description":"Another sweep for this organization holds the lock. Nothing was transferred and nothing was marked swept; try again shortly.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["sweep_in_progress"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"503":{"description":"The pass failed. Reported for any thrown error, including a platform wallet that could not be provisioned and a database failure mid-sweep.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["payto_provisioning_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/orgs/{orgId}/approvals/{id}":{"get":{"deprecated":true,"summary":"Read one approval, including the tool arguments held for review","description":"DEPRECATED alias of `GET /v1/approvals/{id}` (ent#979), kept for two releases. Same handler, same `approvals:read` scope; switch the path and nothing else changes.\n\nThe whole row a rule parked for a human, `tool_input` included, which is what makes the decision reviewable rather than a yes or no on an id. Two selected columns are deliberately off the wire: `decision_history`, and `metadata`, where the decision path records whether the approver's identity was verified or merely asserted.\n\nAn approval belonging to another org is indistinguishable from one that does not exist: both are 404, and an `{orgId}` that is not the credential's own org is 404 before any read.","parameters":[{"schema":{"type":"string"},"required":true,"name":"orgId","in":"path"},{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"org_id":{"type":"string"},"project_id":{"type":["string","null"]},"session_id":{"type":["string","null"]},"agent_id":{"type":"string"},"matched_rule_id":{"type":"string"},"matched_rule_name":{"type":"string"},"tool_name":{"type":"string"},"tool_input":{"type":"object","additionalProperties":{},"description":"The tool arguments the rule held for review, verbatim."},"status":{"type":"string","enum":["pending","approved","denied","expired","execution_failed"],"description":"Closed by the table's own CHECK constraint, not by convention."},"created_at":{"type":"string","format":"date-time"},"expires_at":{"type":"string","format":"date-time"},"decided_at":{"type":["string","null"],"format":"date-time"},"decided_by":{"type":["string","null"]},"decision_reason":{"type":["string","null"]},"executed_at":{"type":["string","null"],"format":"date-time"},"execution_result":{"type":["object","null"],"additionalProperties":{}}},"required":["id","org_id","project_id","session_id","agent_id","matched_rule_id","matched_rule_name","tool_name","tool_input","status","created_at","expires_at","decided_at","decided_by","decision_reason","executed_at","execution_result"]}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}}},"/v1/orgs/{orgId}/approvals/health":{"get":{"deprecated":true,"summary":"Queue depth and the age of the oldest pending approval","description":"DEPRECATED alias of `GET /v1/approvals/health` (ent#979), kept for two releases. Same handler, same `approvals:read` scope.\n\nThe operator snapshot behind a queue alarm: how many approvals are pending for this org and how long the oldest has been waiting. `status` is the literal `ok` and carries no verdict, so alert on `oldest_pending_age_seconds` rather than on it. Both counters are null or zero on an empty queue.\n\n`last_sweep_at` is PLATFORM-WIDE, not per org: it is the last run of the expiry and orphan sweeps across the deployment. A stale value there means approvals may be sitting past their `expires_at` without being marked expired anywhere, which is a different fault from a deep queue.\n\nAn `{orgId}` that is not the credential's own org is 404.","parameters":[{"schema":{"type":"string"},"required":true,"name":"orgId","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"pending_count":{"type":"integer"},"oldest_pending_age_seconds":{"type":["integer","null"],"description":"Null when nothing is pending. Rounded to whole seconds."},"last_sweep_at":{"type":["string","null"],"format":"date-time","description":"Deployment-wide, not scoped to this org."}},"required":["status","pending_count","oldest_pending_age_seconds","last_sweep_at"]}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}}},"/v1/orgs/{orgId}/audit/health":{"get":{"deprecated":true,"summary":"How far the chain verifier got, and whether what it walked was linked","description":"DEPRECATED alias of `GET /v1/audit-events/health` (ent#979), kept for two releases. Same handler, same `audit:read` scope.\n\nRead `actionable_status` first; the four legacy top-level fields (`status`, `last_sequence_number`, `last_checked_at`, `detail`) are kept unchanged for callers written before the expanded body and answer a narrower question.\n\nThe two fields that carry bad news quietly are inside `verification.chain_link_check`. `unverifiable_segments` above zero means a stretch of the chain has no verified link, so nothing certifies it either way, and `watermark_pinned_by_break_at` non-null means nothing ABOVE that sequence is verified at all: the forward walk stops at the break. Neither clears by acknowledging an incident.\n\nAn `{orgId}` that is not the credential's own org is 403 `forbidden`.","parameters":[{"schema":{"type":"string"},"required":true,"name":"orgId","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["healthy","degraded"]},"last_sequence_number":{"type":"integer"},"last_checked_at":{"type":"string","format":"date-time"},"detail":{"type":"string"},"actionable_status":{"type":"string","enum":["verifying","healthy","catching_up","link_unverifiable","degraded","broken"],"description":"The field to alert on. `link_unverifiable` is not `degraded`: it means part of the chain was never verified either way, whereas `degraded` means tampering was detected."},"verification":{"type":"object","properties":{"window_days":{"type":"integer"},"watermark_sequence":{"type":"integer"},"watermark_at":{"type":["string","null"],"format":"date-time"},"watermark_entry_hash":{"type":["string","null"]},"coverage_ratio":{"type":"number"},"hot_bucket_oldest_verified_at":{"type":["string","null"],"format":"date-time"},"hot_bucket_max_staleness_seconds":{"type":"integer"},"writer_tip_check_passed":{"type":["boolean","null"]},"writer_tip_check_at":{"type":["string","null"],"format":"date-time"},"chain_link_check":{"type":"object","properties":{"unverifiable_segments":{"type":"integer","description":"Stretches the verifier reached but could not close. Absence of proof, not a tampering count; while it is above zero `actionable_status` is never `healthy`."},"oldest_unverifiable_segment":{"type":["object","null"],"properties":{"from_sequence":{"type":"integer"},"to_sequence":{"type":"integer"},"reason":{"type":"string","enum":["sequence_gap","no_anchor"]},"first_detected_at":{"type":"string","format":"date-time"}},"required":["from_sequence","to_sequence","reason","first_detected_at"]},"writer_tip_link_unverifiable":{"type":"boolean"},"watermark_pinned_by_break_at":{"type":["integer","null"],"description":"When non-null, nothing above this sequence is verified: the walk stops at the break and cannot pass it. Not cleared by acknowledging the incident, only by repairing the chain."}},"required":["unverifiable_segments","oldest_unverifiable_segment","writer_tip_link_unverifiable","watermark_pinned_by_break_at"]}},"required":["window_days","watermark_sequence","watermark_at","watermark_entry_hash","coverage_ratio","hot_bucket_oldest_verified_at","hot_bucket_max_staleness_seconds","writer_tip_check_passed","writer_tip_check_at","chain_link_check"]},"incidents":{"type":"object","properties":{"open_count":{"type":"integer"},"acknowledged_count_30d":{"type":"integer"},"open_truncated":{"type":"boolean"},"open":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"detected_at":{"type":"string","format":"date-time"},"detected_at_sequence":{"type":"integer"}},"required":["id","detected_at","detected_at_sequence"]}}},"required":["open_count","acknowledged_count_30d","open_truncated","open"]},"initial_walk":{"type":"object","properties":{"in_progress":{"type":"boolean"},"started_at":{"type":["string","null"],"format":"date-time"},"events_processed":{"type":"integer"}},"required":["in_progress","started_at","events_processed"]}},"required":["status","last_sequence_number","last_checked_at","detail","actionable_status","verification","incidents","initial_walk"]}}}},"403":{"description":"The `{orgId}` in the path is not the authenticated org.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["forbidden"]}},"required":["error"]}}}}}}},"/v1/orgs/{orgId}/audit/incidents":{"get":{"deprecated":true,"summary":"List chain-integrity incidents","description":"DEPRECATED alias of `GET /v1/audit-events/incidents` (ent#979), kept for two releases. Same handler, same `audit:read` scope.\n\nNewest first by `detected_at`, capped at 100 rows and NOT paginated: there is no cursor, so an org with more than 100 incidents cannot be walked past the first page from this route.\n\nAn `{orgId}` that is not the credential's own org is 403 `forbidden`.","parameters":[{"schema":{"type":"string"},"required":true,"name":"orgId","in":"path"},{"schema":{"type":"string","description":"Comma-separated, from `open`, `acknowledged` and `all`; `all` wins over anything sent with it. Default `open`. Unknown tokens are DROPPED rather than refused, and the request is only 400 `invalid_status` when nothing recognisable survives."},"required":false,"description":"Comma-separated, from `open`, `acknowledged` and `all`; `all` wins over anything sent with it. Default `open`. Unknown tokens are DROPPED rather than refused, and the request is only 400 `invalid_status` when nothing recognisable survives.","name":"status","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"incidents":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"org_id":{"type":"string"},"detected_at":{"type":"string","format":"date-time"},"detected_at_sequence":{"type":"integer"},"discovered_by_run_id":{"type":["string","null"]},"status":{"type":"string","enum":["open","acknowledged"]},"acknowledged_at":{"type":["string","null"],"format":"date-time"},"acknowledged_by":{"type":["string","null"]},"incident_reference":{"type":["string","null"]},"resolution_summary":{"type":["string","null"]},"anpd_notification_status":{"type":["string","null"],"enum":["not_required","notified"]},"personal_data_affected":{"type":["boolean","null"]},"affected_from_sequence":{"type":["integer","null"]},"affected_to_sequence":{"type":["integer","null"]},"ack_event_entry_hash":{"type":["string","null"],"description":"The chain entry the acknowledgement itself wrote. Null while the incident is open."},"created_at":{"type":"string","format":"date-time"}},"required":["id","org_id","detected_at","detected_at_sequence","discovered_by_run_id","status","acknowledged_at","acknowledged_by","incident_reference","resolution_summary","anpd_notification_status","personal_data_affected","affected_from_sequence","affected_to_sequence","ack_event_entry_hash","created_at"]}}},"required":["incidents"]}}}},"400":{"description":"No recognisable value survived the `status` filter.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_status"]}},"required":["error"]}}}},"403":{"description":"The `{orgId}` in the path is not the authenticated org.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["forbidden"]}},"required":["error"]}}}}}}},"/v1/orgs/{orgId}/audit/incidents/{id}/acknowledge":{"post":{"deprecated":true,"summary":"Acknowledge a chain-integrity incident and write the acknowledgement into the chain","description":"DEPRECATED alias of `POST /v1/audit-events/incidents/{id}/acknowledge` (ent#979), kept for two releases. Same handler, same `audit:write` scope.\n\nNot a status flip: the acknowledgement is itself appended to the audit chain, and the incident row records the entry it wrote in `ack_event_entry_hash`. The append and the `open` to `acknowledged` transition share one transaction, so a reader never sees one without the other. `chain_sequence_number` in the response is that entry's sequence.\n\nAUTHORIZATION IS NOT THE API KEY ALONE. A project API key (bearer) is refused with 403 `human_session_required`; the call has to arrive on a service credential carrying `x-codespar-user`, and that user must hold admin or owner in the org. A missing header is 401 `unauthenticated`.\n\nA second acknowledgement, including one that loses a concurrent race, is 409 `already_acknowledged` with the current row attached rather than an opaque conflict. 503 `lock_timeout` means the chain lock was not available and nothing was written; that one is safe to retry.\n\nAn `{orgId}` that is not the credential's own org is 404 `incident_not_found`, the same answer as an id that does not exist.","parameters":[{"schema":{"type":"string"},"required":true,"name":"orgId","in":"path"},{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"resolution_summary":{"type":"string","description":"Required, and whitespace alone does not satisfy it. The route strips C0 control characters and DEL, then refuses a value whose TRIMMED remainder is empty with 400 `field_invalid` / `empty` — so `\" \"` is rejected, not accepted. The 8000 character ceiling is then applied to the stripped value BEFORE trimming, so surrounding whitespace does count against the limit even though it cannot satisfy the emptiness check."},"anpd_notification_status":{"type":"string","enum":["not_required","notified"],"description":"Required and terminal. `pending_assessment` is refused with 400 `pending_assessment_not_terminal`: it is a console state and is never persisted."},"personal_data_affected":{"type":"boolean"},"incident_reference":{"type":["string","null"],"description":"Optional. Control-stripped first; the 256 character ceiling is applied to the stripped value. Unlike `resolution_summary` this one is not trimmed and an empty string is accepted."},"affected_from_sequence":{"type":["number","null"],"description":"Send with `affected_to_sequence` or with neither; one without the other is 400 `paired_required`. The route checks only that the value is a JSON number and that `from` is not greater than `to`. It does NOT check integrality, so this schema does not claim an integer the route would accept a fraction for; send whole numbers, because the column behind it is an integer."},"affected_to_sequence":{"type":["number","null"],"description":"Paired with `affected_from_sequence`; same rules."}},"required":["resolution_summary","anpd_notification_status","personal_data_affected"]}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"incident":{"type":"object","properties":{"id":{"type":"string"},"org_id":{"type":"string"},"detected_at":{"type":"string","format":"date-time"},"detected_at_sequence":{"type":"integer"},"discovered_by_run_id":{"type":["string","null"]},"status":{"type":"string","enum":["open","acknowledged"]},"acknowledged_at":{"type":["string","null"],"format":"date-time"},"acknowledged_by":{"type":["string","null"]},"incident_reference":{"type":["string","null"]},"resolution_summary":{"type":["string","null"]},"anpd_notification_status":{"type":["string","null"],"enum":["not_required","notified"]},"personal_data_affected":{"type":["boolean","null"]},"affected_from_sequence":{"type":["integer","null"]},"affected_to_sequence":{"type":["integer","null"]},"ack_event_entry_hash":{"type":["string","null"],"description":"The chain entry the acknowledgement itself wrote. Null while the incident is open."},"created_at":{"type":"string","format":"date-time"}},"required":["id","org_id","detected_at","detected_at_sequence","discovered_by_run_id","status","acknowledged_at","acknowledged_by","incident_reference","resolution_summary","anpd_notification_status","personal_data_affected","affected_from_sequence","affected_to_sequence","ack_event_entry_hash","created_at"]},"chain_sequence_number":{"type":"integer","description":"Sequence of the chain entry this acknowledgement appended."}},"required":["incident","chain_sequence_number"]}}}},"400":{"description":"A terminal ANPD status is required, or one field failed its check. `field` names it and `reason` is one of `empty`, `too_long`, `invalid_type`, `invalid_enum`, `paired_required` or `range_inconsistent`; `limit` is present only on `too_long`. `resolution_summary` reports `empty` both when it is missing or not a string and when it is present but blank after trimming.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"string","enum":["pending_assessment_not_terminal"]}},"required":["error"]},{"type":"object","properties":{"error":{"type":"string","enum":["field_invalid"]},"field":{"type":"string"},"reason":{"type":"string","enum":["empty","too_long","invalid_type","invalid_enum","paired_required","range_inconsistent"]},"limit":{"type":"integer"}},"required":["error","field","reason"]}]}}}},"401":{"description":"No `x-codespar-user` header on a service credential.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["unauthenticated"]}},"required":["error"]}}}},"403":{"description":"A project API key cannot acknowledge, or the named user is below admin.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["human_session_required","insufficient_role"]}},"required":["error"]}}}},"404":{"description":"No such incident for this org.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["incident_not_found"]}},"required":["error"]}}}},"409":{"description":"Already acknowledged. The current row is attached, so a retry needs no second read.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["already_acknowledged"]},"incident":{"type":["object","null"],"properties":{"id":{"type":"string"},"org_id":{"type":"string"},"detected_at":{"type":"string","format":"date-time"},"detected_at_sequence":{"type":"integer"},"discovered_by_run_id":{"type":["string","null"]},"status":{"type":"string","enum":["open","acknowledged"]},"acknowledged_at":{"type":["string","null"],"format":"date-time"},"acknowledged_by":{"type":["string","null"]},"incident_reference":{"type":["string","null"]},"resolution_summary":{"type":["string","null"]},"anpd_notification_status":{"type":["string","null"],"enum":["not_required","notified"]},"personal_data_affected":{"type":["boolean","null"]},"affected_from_sequence":{"type":["integer","null"]},"affected_to_sequence":{"type":["integer","null"]},"ack_event_entry_hash":{"type":["string","null"],"description":"The chain entry the acknowledgement itself wrote. Null while the incident is open."},"created_at":{"type":"string","format":"date-time"}},"required":["id","org_id","detected_at","detected_at_sequence","discovered_by_run_id","status","acknowledged_at","acknowledged_by","incident_reference","resolution_summary","anpd_notification_status","personal_data_affected","affected_from_sequence","affected_to_sequence","ack_event_entry_hash","created_at"]}},"required":["error","incident"]}}}},"503":{"description":"The per-org chain lock was not available. Nothing was written; retry.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["lock_timeout"]}},"required":["error"]}}}}}}},"/v1/orgs/{orgId}/audit/config":{"get":{"deprecated":true,"summary":"Read the chain verification windows and staleness budgets","description":"DEPRECATED alias of `GET /v1/audit-events/config` (ent#979), kept for two releases. Same handler, same `audit:read` scope.\n\nThe thresholds the verifier and `GET /v1/orgs/{orgId}/audit/health` measure against. An org with no stored row gets the defaults rather than a 404, with `created_at` and `updated_at` set to now, so a client cannot tell a seeded row from a defaulted one by the body alone.\n\nAn `{orgId}` that is not the credential's own org is 403 `insufficient_role`, which is the code this pair uses for a tenant mismatch rather than a role failure.","parameters":[{"schema":{"type":"string"},"required":true,"name":"orgId","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"org_id":{"type":"string"},"verification_window_days":{"type":"integer"},"hot_window_days":{"type":"integer"},"hot_max_staleness_seconds":{"type":"integer"},"warm_window_days":{"type":"integer"},"warm_max_staleness_seconds":{"type":"integer"},"cold_max_staleness_seconds":{"type":"integer"},"initial_walk_completed_at":{"type":["string","null"],"format":"date-time","description":"Null until the first forward walk has reached the chain tip."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"required":["org_id","verification_window_days","hot_window_days","hot_max_staleness_seconds","warm_window_days","warm_max_staleness_seconds","cold_max_staleness_seconds","initial_walk_completed_at","created_at","updated_at"]}}}},"403":{"description":"The `{orgId}` in the path is not the authenticated org.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["insufficient_role"]}},"required":["error"]}}}}}},"patch":{"deprecated":true,"summary":"Update the chain verification windows and staleness budgets","description":"DEPRECATED alias of `PATCH /v1/audit-events/config` (ent#979), kept for two releases. Same handler, same `audit:write` scope.\n\nA sparse patch merged over the current row, or over the defaults when no row exists, and written back as an upsert; the full merged config is returned. A field whose value is not a JSON number is IGNORED rather than refused, so a typo in a key or a numeric string sent as text leaves the current value in place and still answers 200. A value that IS a number is taken as sent: the route does not check that it is a whole number, so send integers — the columns behind these fields are integers, and nothing in the request path will tell you a fraction was not what you meant.\n\nTwo invariants are checked on the MERGED result, not on the patch, so a one-field change can be refused because of a field you did not send: `hot_max_staleness_seconds` may not exceed 259200 seconds (72 hours), the regulatory floor on how stale verification may get, and the windows must stay ordered hot within warm within the verification window.\n\nAUTHORIZATION IS NOT THE API KEY ALONE, exactly as on the acknowledge route: bearer is 403 `human_session_required`, a missing `x-codespar-user` is 401, and the named user must hold admin or owner.\n\nAn `{orgId}` that is not the credential's own org is 403 `insufficient_role`.","parameters":[{"schema":{"type":"string"},"required":true,"name":"orgId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"verification_window_days":{"type":"number"},"hot_window_days":{"type":"number"},"hot_max_staleness_seconds":{"type":"number"},"warm_window_days":{"type":"number"},"warm_max_staleness_seconds":{"type":"number"},"cold_max_staleness_seconds":{"type":"number"}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"org_id":{"type":"string"},"verification_window_days":{"type":"integer"},"hot_window_days":{"type":"integer"},"hot_max_staleness_seconds":{"type":"integer"},"warm_window_days":{"type":"integer"},"warm_max_staleness_seconds":{"type":"integer"},"cold_max_staleness_seconds":{"type":"integer"},"initial_walk_completed_at":{"type":["string","null"],"format":"date-time","description":"Null until the first forward walk has reached the chain tip."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"required":["org_id","verification_window_days","hot_window_days","hot_max_staleness_seconds","warm_window_days","warm_max_staleness_seconds","cold_max_staleness_seconds","initial_walk_completed_at","created_at","updated_at"]}}}},"400":{"description":"The merged config broke an invariant. `limit` carries the ceiling on `regulatory_minimum_violation`, and the three merged window values on `window_ordering_invalid`.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"string","enum":["regulatory_minimum_violation"]},"limit":{"type":"integer"}},"required":["error","limit"]},{"type":"object","properties":{"error":{"type":"string","enum":["window_ordering_invalid"]},"limit":{"type":"object","properties":{"hot":{"type":"number"},"warm":{"type":"number"},"window":{"type":"number"}},"required":["hot","warm","window"]}},"required":["error","limit"]}]}}}},"401":{"description":"No `x-codespar-user` header on a service credential.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["unauthenticated"]}},"required":["error"]}}}},"403":{"description":"The `{orgId}` is not the authenticated org, a project API key was used, or the named user is below admin.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["insufficient_role","human_session_required"]}},"required":["error"]}}}}}}},"/v1/orgs/{orgId}/data-subjects/{userId}/anonymize":{"post":{"summary":"Erase a data subject's personal data across the audit chain (LGPD Art. 18)","description":"Takes no body: the subject is the `{userId}` path segment, matched against `user_id` in the chain's event payloads. Requires the `data-subjects:anonymize` scope.\n\nThe call writes a `dsr_request` chain event, then splits every matching row in two. Rows under a fiscal hold (settlement and invoice events, plus anything whose payload sets `fiscal_hold`) are NOT erased now: they are scheduled for five years after the event's own timestamp and reported in `deferred_entries`. Everything else has its personal fields nulled in place, in one transaction with a single witness event that covers the whole batch.\n\nIDEMPOTENT BY CONSTRUCTION, but the counters are not. Re-running for the same subject finds the already-tombstoned rows excluded and the deferred rows already scheduled, so it erases nothing twice; it still appends a new `dsr_request` event and answers with zeroes. Do not read a second call's zeroes as 'nothing matched'.\n\nA `{userId}` that is empty or longer than 256 characters is 400. A `{orgId}` that is not the credential's own org is 403 `forbidden`.","parameters":[{"schema":{"type":"string"},"required":true,"name":"orgId","in":"path"},{"schema":{"type":"string","description":"1 to 256 characters."},"required":true,"description":"1 to 256 characters.","name":"userId","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"dsr_id":{"type":"string","description":"The entry hash of the `dsr_request` chain event this call wrote."},"dsr_event_entry_hash":{"type":"string"},"rows_anonymized_immediately":{"type":"integer"},"rows_deferred":{"type":"integer"},"deferred_entries":{"type":"array","items":{"type":"object","properties":{"event_id":{"type":"string"},"anonymize_after":{"type":"string","format":"date-time","description":"The event's own timestamp plus five years."}},"required":["event_id","anonymize_after"]}}},"required":["dsr_id","dsr_event_entry_hash","rows_anonymized_immediately","rows_deferred","deferred_entries"]}}}},"400":{"description":"`{userId}` was empty or over 256 characters.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_user_id"]}},"required":["error"]}}}},"403":{"description":"The `{orgId}` in the path is not the authenticated org.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["forbidden"]}},"required":["error"]}}}}}}},"/v1/orgs/{orgId}/agents":{"post":{"deprecated":true,"summary":"Register an agent identity and mint its first signing key","description":"DEPRECATED alias of `POST /v1/agents` (ent#979), kept for two releases. Same handler, same `agents:write` scope; the canonical path drops the `{orgId}` segment because the org is always the authenticated one.\n\nOne call does both: it derives a `did:web` from the org and the handle, creates the agent, and mints key number one. The public key comes back base64; the private half never leaves the vault and is not returned here or anywhere else.\n\n`principal_ref` is an OPAQUE KYC handle, and the check that keeps it opaque counts DIGITS, not digit runs. Every non-digit is removed from the whole value first, and what remains is refused if it is eleven characters or longer — so the rule is 'eleven or more digits anywhere in the value', and a handle like `acct-1-2-3-4-5-6-7-8-9-0-1`, which shows no run of digits at all, is refused. The reason for the rule is that this value is signed into the mandate presentation token and served to counterparties without authentication, so a CPF or CNPJ must never reach it. It is also not proof of KYC; nothing on this route verifies anything.\n\nRegistering an agent that already holds an active key is 409 `agent_already_registered`. 409 `agent_did_owned_elsewhere` means the derived `did:web` is held by another org's row, which is a caller-state conflict, not a server fault: register under a different handle.","parameters":[{"schema":{"type":"string"},"required":true,"name":"orgId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"agent_id":{"type":"string","minLength":1},"display_name":{"type":"string","minLength":1},"principal_ref":{"type":"string","minLength":1}},"required":["agent_id","principal_ref"]}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"agent_did":{"type":"string","description":"`did:web:` derived from the org and the handle."},"kid":{"type":"string","description":"`<agent_did>#<n>`."},"pubkey":{"type":"string","description":"Raw 32-byte Ed25519 public key, base64."},"status":{"type":"string","enum":["active"]}},"required":["agent_did","kid","pubkey","status"]}}}},"400":{"description":"The body failed the schema (`details.issues` carries the Zod issues), or the org or the handle contains characters a `did:web` cannot hold.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["missing_required_field","org_id_not_did_safe","agent_id_not_did_safe"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"403":{"description":"The `{orgId}` in the path is not the authenticated org.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["forbidden"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"The agent already has an active key, or the derived `did:web` belongs to another org.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["agent_already_registered","agent_did_owned_elsewhere"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/orgs/{orgId}/agents/{agentId}/keys/rotate":{"post":{"deprecated":true,"summary":"Retire the agent's active key and mint a fresh one","description":"DEPRECATED alias of `POST /v1/agents/{agentId}/keys/rotate` (ent#979), kept for two releases. Same handler, same `agents:write` scope.\n\nTakes no body. `{agentId}` is the HANDLE the agent was registered under, not its `did:web`; the DID is derived from the org and the handle the same way registration derived it.\n\nROTATION IS NOT REVOCATION. The retire and the mint share one transaction, and the retired key still verifies everything it signed BEFORE retirement, so no previously issued mandate is invalidated. It simply cannot sign anything new. To actually invalidate a key, use the revoke route below.\n\n409 `no_active_key` means the agent exists but holds nothing to rotate, which is the state an agent row sits in before its first key.","parameters":[{"schema":{"type":"string"},"required":true,"name":"orgId","in":"path"},{"schema":{"type":"string","description":"The registration handle, not the `did:web`."},"required":true,"description":"The registration handle, not the `did:web`.","name":"agentId","in":"path"}],"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"agent_did":{"type":"string"},"kid":{"type":"string","description":"The new active key."},"pubkey":{"type":"string","description":"Raw 32-byte Ed25519 public key, base64."},"retired_kid":{"type":"string","description":"The key just retired. It still verifies what it signed before retirement; it can only no longer sign anything new."},"status":{"type":"string","enum":["active"]}},"required":["agent_did","kid","pubkey","retired_kid","status"]}}}},"403":{"description":"The `{orgId}` in the path is not the authenticated org.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["forbidden"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"No agent with that handle in this org.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["agent_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"The agent holds no active key to rotate.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["no_active_key"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/orgs/{orgId}/agents/{agentId}/keys/{kid}/revoke":{"post":{"deprecated":true,"summary":"Hard-revoke one of an agent's signing keys","description":"DEPRECATED alias of `POST /v1/agents/{agentId}/keys/{kid}/revoke` (ent#979), kept for two releases. Same handler, same `agents:write` scope.\n\nTakes no body. Unlike rotation, this is the verb that stops a key from counting: the mandate spend gate resolves the key an existing mandate was signed with and refuses the spend once that key is revoked, so revoking reaches BACKWARDS into mandates already issued.\n\n`{kid}` is a full key id of the form `<did:web:...>#<n>`, and its `#` MUST be percent-encoded as `%23` to survive as one path segment. This revokes ONE key; it does not revoke the agent identity.\n\nRevoking an already-revoked key is 409 rather than a no-op, so a retry after a timeout is distinguishable from a first call.","parameters":[{"schema":{"type":"string"},"required":true,"name":"orgId","in":"path"},{"schema":{"type":"string","description":"The registration handle, not the `did:web`."},"required":true,"description":"The registration handle, not the `did:web`.","name":"agentId","in":"path"},{"schema":{"type":"string","description":"`<did:web:...>#<n>`, with the `#` percent-encoded as `%23`."},"required":true,"description":"`<did:web:...>#<n>`, with the `#` percent-encoded as `%23`.","name":"kid","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"kid":{"type":"string"},"status":{"type":"string","enum":["revoked"]},"revoked_at":{"type":"string","format":"date-time"}},"required":["kid","status","revoked_at"]}}}},"403":{"description":"The `{orgId}` in the path is not the authenticated org.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["forbidden"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"No such key for that agent in this org.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["agent_key_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"That key is already revoked.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["key_already_revoked"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/orgs/{orgId}/mandates":{"post":{"summary":"Issue an org-side mandate (off by default, see the 501)","description":"OFF BY DEFAULT, AND THE FIRST THING THIS ROUTE DOES IS CHECK THAT. Unless the deployment has explicitly enabled org-side issuance, every call is 501 `not_implemented` before any row is written. On a default deployment that 501 is the expected answer, not an outage.\n\nTAKE THE FLAG AS THE GATE, NOT THE 501's SENTENCE AS THE STATE. The message on that 501 says the aggregate spend cap 'is not yet enforced by any spend path'. That sentence is older than the wallet gate and no longer describes the system: `POST /v1/wallets/{id}/execute` charges a mandate before the payment is attempted, decrements `remaining_amount` under a guard that refuses an over-cap spend, and writes the consumption row this document returns from `GET /v1/orgs/{orgId}/mandates/{id}`. So a mandate issued here is enforceable; whether it can be issued at all is the deployment's flag.\n\nWhen enabled, issuance is gated by the org's policy rules for `codespar:mandate:issue`, evaluated before any write. The three non-allowed outcomes are distinct on purpose: 403 `policy_denied` is a rule refusing, 413 `payload_too_large` is an input too big to evaluate, and 503 `policy_engine_error` means the engine could NOT evaluate. The 503 is infrastructure and is retriable; the 403 is not.\n\n`expires_at` is UNIX SECONDS and must be in the future. `purposes` must be printable ASCII, and the mandate is spendable only for a purpose in that list. `amount` and `max_amount` are decimal strings with at most two places.\n\nThe 201 body has two shapes and the request does not choose between them: a REGISTERED agent (active, with an active key) gets a dual-signed presentation token plus `format_version`, `agent_did` and `kid`, and an unregistered one gets the plain token and nothing else. `principal_kyc_verified` is always false.\n\nTHE `{orgId}` SEGMENT IS NOT READ. The mandate is always issued for the credential's own org. A body over 1 MiB is refused by the server before the handler runs, with the framework's own error body rather than the envelope below.","parameters":[{"schema":{"type":"string"},"required":true,"name":"orgId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"agent_id":{"type":"string","minLength":1},"type":{"type":"string","enum":["payment","subscription","delegation"]},"amount":{"type":"string","minLength":1,"pattern":"^\\d+(\\.\\d{1,2})?$"},"currency":{"type":"string","minLength":3,"maxLength":3},"purposes":{"type":"array","items":{"type":"string","minLength":1},"minItems":1},"expires_at":{"type":"integer","exclusiveMinimum":0},"max_amount":{"type":["string","null"],"pattern":"^\\d+(\\.\\d{1,2})?$"},"denomination":{"type":["string","null"],"minLength":3,"maxLength":3},"parent_id":{"type":["string","null"],"minLength":1}},"required":["agent_id","type","amount","currency","purposes","expires_at"]}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"mandate_id":{"type":"string"},"signed_token":{"type":"string"},"format_version":{"type":"number","enum":[3]},"agent_did":{"type":"string"},"kid":{"type":"string"},"principal_kyc_verified":{"type":"boolean","enum":[false],"description":"Always false. No KYC verification source exists yet, so this never asserts verification; do not read it as a KYC result."},"expires_at":{"type":"string","format":"date-time"}},"required":["mandate_id","signed_token","format_version","agent_did","kid","principal_kyc_verified","expires_at"]},{"type":"object","properties":{"mandate_id":{"type":"string"},"signed_token":{"type":"string"},"expires_at":{"type":"string","format":"date-time"}},"required":["mandate_id","signed_token","expires_at"]}]}}}},"400":{"description":"The body failed the schema (`details.issues` carries the Zod issues), `expires_at` was not in the future, or a purpose was not printable ASCII.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["missing_required_field","expires_at_in_past","invalid_purpose_encoding"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"403":{"description":"A policy rule refused issuance. `details` carries `reason`, `ruleType` and `ruleId`, plus `approval_id` and `expires_at` when the rule opened an approval instead of denying outright.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["policy_denied"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"413":{"description":"The policy input was too large to evaluate.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["payload_too_large"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"501":{"description":"Org-side issuance is disabled on this deployment. No mandate was created.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_implemented"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"503":{"description":"The policy engine could not evaluate the request. Nothing was evaluated and nothing was issued; retry.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["policy_engine_error"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"get":{"summary":"List the org's mandates","description":"Newest first, capped by `limit` (max 100) and NOT paginated: there is no cursor, so an org with more mandates than `limit` cannot be walked past the first page from this route. Requires `mandates:read`.\n\n`status` DEFAULTS TO `active`, so an omitted filter hides revoked and expired mandates rather than returning everything; `count` is the length of the returned page and not a total.\n\nTHE `{orgId}` SEGMENT IS NOT READ. The list is always the credential's own org, so a different value in the path changes nothing about the answer. Do not use the path as a tenant filter.","parameters":[{"schema":{"type":"string"},"required":true,"name":"orgId","in":"path"},{"schema":{"type":"string","enum":["active","revoked","expired"],"default":"active"},"required":false,"name":"status","in":"query"},{"schema":{"type":"string","minLength":1},"required":false,"name":"agent_id","in":"query"},{"schema":{"type":["integer","null"]},"required":false,"name":"created_after","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"default":100},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"mandates":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"`mnd_` prefixed."},"org_id":{"type":"string"},"agent_id":{"type":"string"},"type":{"type":"string","enum":["payment","subscription","delegation"]},"status":{"type":"string","enum":["active","revoked","expired"],"description":"Closed by the table's own CHECK constraint."},"format_version":{"type":"integer"},"secret_version":{"type":"integer"},"amount":{"type":"string","description":"Decimal string."},"remaining_amount":{"type":"string","description":"Decimal string, and it MOVES. This is the aggregate cap still available on the mandate: `POST /v1/wallets/{id}/execute` charges this mandate before the payment is attempted, decrementing this value and recording the consumption in one transaction. A spend that would take it below zero is refused with `mandate_cap_exceeded` rather than clamped, so this never goes negative."},"currency":{"type":"string"},"purposes":{"type":"array","items":{"type":"string"}},"max_amount":{"type":["string","null"]},"denomination":{"type":["string","null"]},"parent_id":{"type":["string","null"]},"expires_at":{"type":"string","format":"date-time"},"revoked_at":{"type":["string","null"],"format":"date-time"},"revocation_reason":{"type":["string","null"]},"expired_at":{"type":["string","null"],"format":"date-time"},"signature":{"type":"string"},"authorized_by":{"type":"string"},"created_at":{"type":"string","format":"date-time"}},"required":["id","org_id","agent_id","type","status","format_version","secret_version","amount","remaining_amount","currency","purposes","max_amount","denomination","parent_id","expires_at","revoked_at","revocation_reason","expired_at","signature","authorized_by","created_at"]}},"count":{"type":"integer","description":"Length of `mandates`, not a total."}},"required":["mandates","count"]}}}},"400":{"description":"The query failed the schema; `details.issues` carries the Zod issues.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["missing_required_field"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/orgs/{orgId}/mandates/{id}":{"get":{"summary":"Read one mandate with its consumption history","description":"The mandate row plus every recorded consumption, oldest first. Requires `mandates:read`.\n\nBOTH HALVES MOVE. `remaining_amount` is the cap as stored and `consumptions` is the charge history, and the spend path that changes them is `POST /v1/wallets/{id}/execute`: it charges this mandate before the payment is attempted, decrementing the cap and appending the consumption row in one transaction, and refuses the spend when the cap would be exceeded. A wallet execute that never got past that gate leaves nothing here.\n\nA consumption's `tool_call_id` is the execute's `attempt_id` and is unique per org, so a retried execute reusing its `attempt_id` is charged once: a retry adds no second row and subtracts nothing twice. Reading one row per payment attempt would therefore undercount attempts, not charges.\n\nA mandate belonging to another org is indistinguishable from one that does not exist: both are 404. THE `{orgId}` SEGMENT IS NOT READ, so the isolation comes from the query, not from the path.","parameters":[{"schema":{"type":"string"},"required":true,"name":"orgId","in":"path"},{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`mnd_` prefixed."},"org_id":{"type":"string"},"agent_id":{"type":"string"},"type":{"type":"string","enum":["payment","subscription","delegation"]},"status":{"type":"string","enum":["active","revoked","expired"],"description":"Closed by the table's own CHECK constraint."},"format_version":{"type":"integer"},"secret_version":{"type":"integer"},"amount":{"type":"string","description":"Decimal string."},"remaining_amount":{"type":"string","description":"Decimal string, and it MOVES. This is the aggregate cap still available on the mandate: `POST /v1/wallets/{id}/execute` charges this mandate before the payment is attempted, decrementing this value and recording the consumption in one transaction. A spend that would take it below zero is refused with `mandate_cap_exceeded` rather than clamped, so this never goes negative."},"currency":{"type":"string"},"purposes":{"type":"array","items":{"type":"string"}},"max_amount":{"type":["string","null"]},"denomination":{"type":["string","null"]},"parent_id":{"type":["string","null"]},"expires_at":{"type":"string","format":"date-time"},"revoked_at":{"type":["string","null"],"format":"date-time"},"revocation_reason":{"type":["string","null"]},"expired_at":{"type":["string","null"],"format":"date-time"},"signature":{"type":"string"},"authorized_by":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"consumptions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"mandate_id":{"type":"string"},"amount":{"type":"string","description":"Decimal string."},"call_purpose":{"type":"string","description":"Which of the mandate's `purposes` this charge was booked against. A spend naming a purpose the mandate does not carry is refused, so every value here is one of them."},"tool_call_id":{"type":"string","description":"The `attempt_id` of the wallet execute that charged the mandate, unique per org. A retried execute reusing its `attempt_id` is charged once, so a retry adds no second row here."},"consumed_at":{"type":"string","format":"date-time"}},"required":["id","mandate_id","amount","call_purpose","tool_call_id","consumed_at"]}}},"required":["id","org_id","agent_id","type","status","format_version","secret_version","amount","remaining_amount","currency","purposes","max_amount","denomination","parent_id","expires_at","revoked_at","revocation_reason","expired_at","signature","authorized_by","created_at","consumptions"]}}}},"404":{"description":"No such mandate for this org.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["mandate_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/orgs/{orgId}/mandates/{id}/revoke":{"post":{"summary":"Revoke a mandate","description":"Requires `mandates:write`. The body is optional and carries at most a `reason`; anything else is ignored, and a missing or blank reason is recorded as `revoked by operator`.\n\nNOT IDEMPOTENT, deliberately. Revoking an already-revoked mandate is 409 and an already-expired one is 410, each naming which state it was in, rather than repeating the first call's answer. `prior_status` on the 200 is therefore always `active`.\n\nThe revocation notifies subscribers inside the same transaction as the status change, so a subscriber never learns of a revocation that then rolls back. THE `{orgId}` SEGMENT IS NOT READ; the mandate is resolved against the credential's own org.","parameters":[{"schema":{"type":"string"},"required":true,"name":"orgId","in":"path"},{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string","description":"Free text, trimmed. An absent, non-string or blank value is recorded as `revoked by operator`."}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"revoked_at":{"type":"string","format":"date-time"},"prior_status":{"type":"string","enum":["active"]}},"required":["revoked_at","prior_status"]}}}},"404":{"description":"No such mandate for this org.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["mandate_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"The mandate was already revoked.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["mandate_already_revoked"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"410":{"description":"The mandate had already expired.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["mandate_already_expired"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/paywalls":{"get":{"summary":"List paywalls","description":"Every paywall of the calling credential's org AND project, newest first by creation time.\n\nThere is no pagination and no filter: the query carries no LIMIT and no predicate beyond the two tenancy columns, so a project with thousands of paywalls returns thousands of rows in one body. Read one paywall by id when that is what you need.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"paywalls":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"`pw_` followed by a 16-character nanoid."},"project_id":{"type":"string"},"slug":{"type":"string","description":"The public gateway key. UNIQUE GLOBALLY, not per project: the uniqueness index covers the slug alone, so a slug is first-come across all tenants and a taken one is refused with 400 `slug_conflict` on create."},"name":{"type":"string"},"upstream_url":{"type":"string","description":"Origin the gateway proxies to once a call is paid for."},"price":{"type":"string","description":"`price_atomic` rendered as a decimal USDC string with trailing zeros trimmed ('1000' becomes '0.001'). Derived on the way out, not stored."},"price_atomic":{"type":"string","description":"The stored price: USDC atomic units (6 decimals) as an integer string, to keep the x402 UintString convention exact."},"currency":{"type":"string","description":"'USDC' on every row the create route writes; the column has no CHECK."},"environment":{"type":"string","enum":["live","test"],"description":"Inherited from the calling credential's project at create time."},"payto_kind":{"type":"string","enum":["byo","provisioned"],"description":"'byo': the seller supplied the receiving 0x USDC address. 'provisioned': CodeSpar derived and owns a CDP address for the tenant's consumer."},"payto_address":{"type":"string","description":"The 0x address that receives settlement."},"consumer_id":{"type":["string","null"],"description":"The consumer whose wallet receives, for a provisioned payTo. Null for 'byo'."},"description":{"type":["string","null"]},"category":{"type":["string","null"]},"methods":{"type":["array","null"],"items":{"type":"string"},"description":"HTTP methods this paywall fronts. Null means any method. A method that is not listed is refused by the gateway with 405 `method_not_allowed` and an `Allow` header, BEFORE any 402 challenge, so it cannot be paid for and then proxied."},"pricing_model":{"type":"string","enum":["flat","token","dynamic","time","per_unit","tiered","metered"],"description":"The full set a row may hold. Only 'flat', 'tiered', 'dynamic' and 'metered' can be created today; the create route refuses the other three with 400 `pricing_model_unsupported` rather than storing a model that would bill as flat."},"pricing_tiers":{"type":["array","null"],"items":{"type":"object","properties":{"up_to":{"type":["integer","null"],"description":"EXCLUSIVE upper bound on the paywall's cumulative settled call count for which this tier's price applies: the gateway quotes the first tier whose `up_to` is null or strictly greater than the settled volume. The last tier carries null, meaning unbounded."},"price_atomic":{"type":"string","description":"USDC atomic units (6 decimals) as an integer string."}},"required":["up_to","price_atomic"]},"description":"Ascending price curve for 'tiered'. Null for every other model."},"dynamic_price_url":{"type":["string","null"],"description":"Seller price hook for 'dynamic': the gateway GETs it per request and quotes what it returns. It is fail-safe, not fail-closed: an unsafe, unreachable, non-2xx, malformed or timed-out hook falls back to `price_atomic`, so the gateway can always quote a price."},"metered_config":{"type":["object","null"],"properties":{"basis":{"type":"string","description":"Metering label ('complexity', 'tokens', 'time', 'units'). Informational: the price is base times the reported multiplier whatever the label says."},"base_atomic":{"type":"string","description":"Base price in USDC atomic units, before the multiplier."},"min_mult":{"type":"number","description":"Lower clamp on the multiplier, and the DEFAULT: a missing, malformed or non-positive multiplier header meters at `min_mult`, so a seller that reports nothing charges the least."},"max_mult":{"type":"number","description":"Upper clamp. `base_atomic` times `max_mult` is the CEILING advertised in the 402 and signed by the payer, so a metered call can never settle above it."},"units_header":{"type":"string","description":"Response header the seller reports the multiplier in."}},"required":["basis","base_atomic","min_mult","max_mult","units_header"],"description":"Config for 'metered'. Null for every other model."},"active":{"type":"boolean","description":"The gateway resolves ACTIVE rows only; an inactive paywall stops serving."},"gateway_url":{"type":"string","description":"`https://gw.codespar.dev/<slug>`, built from the slug on the way out. The id does not appear in it."},"created_at":{"type":"string","format":"date-time"}},"required":["id","project_id","slug","name","upstream_url","price","price_atomic","currency","environment","payto_kind","payto_address","consumer_id","description","category","methods","pricing_model","pricing_tiers","dynamic_price_url","metered_config","active","gateway_url","created_at"]}}},"required":["paywalls"]}}}}}}},"/v1/paywalls/{id}":{"get":{"summary":"Read one paywall","description":"The paywall, if it belongs to the calling credential's org and project. The handler has exactly two outcomes, 200 and 404.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`pw_` followed by a 16-character nanoid."},"project_id":{"type":"string"},"slug":{"type":"string","description":"The public gateway key. UNIQUE GLOBALLY, not per project: the uniqueness index covers the slug alone, so a slug is first-come across all tenants and a taken one is refused with 400 `slug_conflict` on create."},"name":{"type":"string"},"upstream_url":{"type":"string","description":"Origin the gateway proxies to once a call is paid for."},"price":{"type":"string","description":"`price_atomic` rendered as a decimal USDC string with trailing zeros trimmed ('1000' becomes '0.001'). Derived on the way out, not stored."},"price_atomic":{"type":"string","description":"The stored price: USDC atomic units (6 decimals) as an integer string, to keep the x402 UintString convention exact."},"currency":{"type":"string","description":"'USDC' on every row the create route writes; the column has no CHECK."},"environment":{"type":"string","enum":["live","test"],"description":"Inherited from the calling credential's project at create time."},"payto_kind":{"type":"string","enum":["byo","provisioned"],"description":"'byo': the seller supplied the receiving 0x USDC address. 'provisioned': CodeSpar derived and owns a CDP address for the tenant's consumer."},"payto_address":{"type":"string","description":"The 0x address that receives settlement."},"consumer_id":{"type":["string","null"],"description":"The consumer whose wallet receives, for a provisioned payTo. Null for 'byo'."},"description":{"type":["string","null"]},"category":{"type":["string","null"]},"methods":{"type":["array","null"],"items":{"type":"string"},"description":"HTTP methods this paywall fronts. Null means any method. A method that is not listed is refused by the gateway with 405 `method_not_allowed` and an `Allow` header, BEFORE any 402 challenge, so it cannot be paid for and then proxied."},"pricing_model":{"type":"string","enum":["flat","token","dynamic","time","per_unit","tiered","metered"],"description":"The full set a row may hold. Only 'flat', 'tiered', 'dynamic' and 'metered' can be created today; the create route refuses the other three with 400 `pricing_model_unsupported` rather than storing a model that would bill as flat."},"pricing_tiers":{"type":["array","null"],"items":{"type":"object","properties":{"up_to":{"type":["integer","null"],"description":"EXCLUSIVE upper bound on the paywall's cumulative settled call count for which this tier's price applies: the gateway quotes the first tier whose `up_to` is null or strictly greater than the settled volume. The last tier carries null, meaning unbounded."},"price_atomic":{"type":"string","description":"USDC atomic units (6 decimals) as an integer string."}},"required":["up_to","price_atomic"]},"description":"Ascending price curve for 'tiered'. Null for every other model."},"dynamic_price_url":{"type":["string","null"],"description":"Seller price hook for 'dynamic': the gateway GETs it per request and quotes what it returns. It is fail-safe, not fail-closed: an unsafe, unreachable, non-2xx, malformed or timed-out hook falls back to `price_atomic`, so the gateway can always quote a price."},"metered_config":{"type":["object","null"],"properties":{"basis":{"type":"string","description":"Metering label ('complexity', 'tokens', 'time', 'units'). Informational: the price is base times the reported multiplier whatever the label says."},"base_atomic":{"type":"string","description":"Base price in USDC atomic units, before the multiplier."},"min_mult":{"type":"number","description":"Lower clamp on the multiplier, and the DEFAULT: a missing, malformed or non-positive multiplier header meters at `min_mult`, so a seller that reports nothing charges the least."},"max_mult":{"type":"number","description":"Upper clamp. `base_atomic` times `max_mult` is the CEILING advertised in the 402 and signed by the payer, so a metered call can never settle above it."},"units_header":{"type":"string","description":"Response header the seller reports the multiplier in."}},"required":["basis","base_atomic","min_mult","max_mult","units_header"],"description":"Config for 'metered'. Null for every other model."},"active":{"type":"boolean","description":"The gateway resolves ACTIVE rows only; an inactive paywall stops serving."},"gateway_url":{"type":"string","description":"`https://gw.codespar.dev/<slug>`, built from the slug on the way out. The id does not appear in it."},"created_at":{"type":"string","format":"date-time"}},"required":["id","project_id","slug","name","upstream_url","price","price_atomic","currency","environment","payto_kind","payto_address","consumer_id","description","category","methods","pricing_model","pricing_tiers","dynamic_price_url","metered_config","active","gateway_url","created_at"]}}}},"404":{"description":"Not Found. A paywall owned by another org or another project is indistinguishable from one that does not exist: the lookup filters on id AND org AND project in the same query, so a cross-tenant read is 404 and never 403.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["paywall_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"delete":{"summary":"Delete a paywall","description":"HARD delete, and 204 with no body. The row is removed rather than deactivated, because a paywall is configuration and not audit data; if what you want is to stop serving while keeping the row, patch `active` to false instead.\n\nWHAT IT DOES NOT DELETE: the settlement evidence and the metered refund attempts. Neither carries a foreign key to the paywall, so both survive the delete. What is lost is the way IN to them, since the stats operation resolves the paywall first and answers 404 once the row is gone. Read the stats before deleting if you need the totals.\n\nThe slug returns to the global namespace and any other tenant may then claim it, so a delete-and-recreate is not guaranteed to get the same gateway URL back.\n\nAUTHORIZATION, as deployed rather than as named, and it is NOT the same for the two credentials this document describes.\n\nWith an OAUTH ACCESS TOKEN the org-role guard in front of this route is ordinary and live: the acting user must be forwarded in `x-codespar-user` and must hold at least the admin role in the organization. No header, or a user who is not an admin, is 403 `insufficient_role` before the handler runs.\n\nWith an API KEY the same guard is in a measurement window: it resolves the forwarded user, RECORDS the request when that user would not have sufficed, and lets it through anyway. So today a key carrying `paywalls:write` deletes without forwarding anybody. A deployment that closes the window refuses those calls with 403 as well, so forward `x-codespar-user` if you have it: it is already required on the token path, it is inert on the key path while the window is open, and it is what keeps the call working when the window closes.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"No Content"},"403":{"description":"Forbidden. The acting user does not satisfy the org-role guard in front of this route. With an OAuth access token this is the live behaviour when `x-codespar-user` is absent or names a user below admin; with an API key it appears only where the deployment enforces the same guard.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"`insufficient_role` on the ordinary path."},"required":{"type":"string","enum":["admin"],"description":"The minimum org role this route asks for."},"message":{"type":"string","description":"Present only on the enforced API-key path."},"status":{"type":"number","enum":[403],"description":"Present only on the enforced API-key path."}},"required":["error","required"]}}}},"404":{"description":"Not Found. A paywall owned by another org or another project is indistinguishable from one that does not exist: the lookup filters on id AND org AND project in the same query, so a cross-tenant read is 404 and never 403.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["paywall_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/paywalls/{id}/stats":{"get":{"summary":"Earnings for one paywall","description":"What this paywall has settled and what the seller kept, aggregated at read time from the evidence the gateway already writes. No counter is stored for it.\n\nATTRIBUTION is by the paywall's gateway URL: a settlement counts when the x402 intent's resource URL is exactly `https://gw.codespar.dev/<slug>` or any path underneath it. Slugs are globally unique, so that prefix names exactly one paywall; the org filter on the aggregate is defence in depth on top of it.\n\nA slug cannot be edited, so attribution never moves under a live paywall. Deleting the paywall does end the reading: the evidence rows outlive it, and the id that reached them is gone.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"paywall_id":{"type":"string"},"slug":{"type":"string"},"currency":{"type":"string"},"settled_count":{"type":"integer","description":"Settled gateway calls attributed to this paywall."},"gross_atomic":{"type":"string","description":"Sum of what payers authorized, in USDC atomic units."},"gross":{"type":"string","description":"`gross_atomic` as a decimal USDC string."},"refunded_atomic":{"type":"string","description":"Sum of the metered refunds that are owed or already gone: attempts in status 'claimed', 'sent' or 'confirmed'. The refund lane writes five statuses and only these three are subtracted; the other two, 'waived' (below the dust floor, so no transfer is made) and 'failed' (the transfer or its transaction reverted), leave the money with the seller."},"refunded":{"type":"string","description":"`refunded_atomic` as a decimal USDC string."},"net_atomic":{"type":"string","description":"`gross_atomic` minus `refunded_atomic`, clamped at zero rather than allowed to go negative. Flat, tiered and dynamic paywalls have no refunds, so net equals gross there; on the metered lane the payer signs a ceiling and the difference goes back on-chain, so gross alone would report money the seller does not keep."},"net":{"type":"string","description":"`net_atomic` as a decimal USDC string."},"last_settled_at":{"type":["string","null"],"format":"date-time","description":"Most recent settlement, UTC. Null when nothing has settled."}},"required":["paywall_id","slug","currency","settled_count","gross_atomic","gross","refunded_atomic","refunded","net_atomic","net","last_settled_at"]}}}},"404":{"description":"Not Found. A paywall owned by another org or another project is indistinguishable from one that does not exist: the lookup filters on id AND org AND project in the same query, so a cross-tenant read is 404 and never 403.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["paywall_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/projects":{"post":{"summary":"Create a project","description":"Creates a project in the organization this credential resolves to.\n\nROLE. This is an administrative operation and it carries the `admin` role gate. On a credential of the kind this document describes — an API key or an OAuth access token — that gate does NOT refuse today. It resolves the user forwarded in the `x-codespar-user` header against this organization's members, records the outcome when no user was forwarded, the user is not a member of the organization, or the user ranks below `admin`, and then lets the request through. Refusal is behind a server-side rollout flag that is currently off. Forward an `admin` or `owner` user in `x-codespar-user` now: when the flag is turned on, a call that does not carry one starts being answered with 403 instead of being served.\n\nSCOPE. A key created with an explicit scope list needs `projects:write` and is answered with 403 without it. A wildcard key passes: the wildcard is what a key gets when it is created without an explicit list, which is every key by default.\n\n`slug` is unique per organization, lowercase alphanumerics plus `_` and `-`, at most 64 characters, and `default` is reserved because the auto-seeded first project already holds it. A slug already taken in the organization is refused with 400 and code `slug_conflict`, not 409, and `details.slug` echoes the value that collided.\n\n`environment` is `test` when omitted and is fixed once the row exists: the update operation has no field for it. A new project is never the organization's default — the insert writes `false` — so promote it afterwards with `PATCH /v1/projects/{id}` if that is what you want.\n\n`settings` seeds the settings sub-resource. Every key is checked against the settings registry, for the environment the project is about to be born with, BEFORE anything is inserted, so an unknown key, a wrong-typed value, or a key that does not apply to that environment refuses the whole call and leaves no project behind. The refusal is 400 `invalid_body` with `details.key` naming the offending setting. Settings are not part of the response body; read them back with `GET /v1/projects/{id}/settings`.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":128},"slug":{"type":"string","minLength":1,"maxLength":64,"pattern":"^[a-z0-9_-]+$"},"environment":{"type":"string","enum":["live","test"]},"settings":{"type":"object","additionalProperties":{}}},"required":["name","slug"]}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`prj_` followed by a 16 character id."},"org_id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string","description":"Unique within the organization."},"is_default":{"type":"boolean","description":"At most one project per organization carries `true`, held by a partial unique index on the table. At LEAST one is not guaranteed by any constraint, and nothing in this API creates one: a project created through this API is always `false`, and the organization's first project is seeded elsewhere."},"environment":{"type":"string","enum":["live","test"],"description":"Fixed at creation. There is no field to change it afterwards."},"created_at":{"type":"string","format":"date-time"}},"required":["id","org_id","name","slug","is_default","environment","created_at"]}}}},"400":{"description":"`invalid_body` when the body or an initial setting fails validation (`details.issues` for the body, `details.key` for a setting). `slug_conflict` when the slug is already taken in this organization.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body","slug_conflict"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"get":{"summary":"List projects","description":"Every project in the organization this credential resolves to, the default first and then oldest to newest.\n\nSCOPE. A key created with an explicit scope list needs `projects:read` and is answered with 403 without it. A wildcard key passes: the wildcard is what a key gets when it is created without an explicit list, which is every key by default.\n\nNo role gate: any credential that resolves to the organization can read this.\n\nNot paginated and not filtered: an organization is expected to hold a handful of projects, so there is no cursor, no limit and no query parameters. The response is always the whole set.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"projects":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"`prj_` followed by a 16 character id."},"org_id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string","description":"Unique within the organization."},"is_default":{"type":"boolean","description":"At most one project per organization carries `true`, held by a partial unique index on the table. At LEAST one is not guaranteed by any constraint, and nothing in this API creates one: a project created through this API is always `false`, and the organization's first project is seeded elsewhere."},"environment":{"type":"string","enum":["live","test"],"description":"Fixed at creation. There is no field to change it afterwards."},"created_at":{"type":"string","format":"date-time"}},"required":["id","org_id","name","slug","is_default","environment","created_at"]}}},"required":["projects"]}}}}}}},"/v1/projects/{id}":{"get":{"summary":"Read one project","description":"The same row the list returns, by id.\n\nSCOPE. A key created with an explicit scope list needs `projects:read` and is answered with 403 without it. A wildcard key passes: the wildcard is what a key gets when it is created without an explicit list, which is every key by default.\n\nNo role gate.\n\nAn id belonging to another organization returns 404, never 403. The organization is part of the query rather than a check after it, so the handler cannot tell `another tenant` from `absent`, and it must not: a 403 would confirm the id exists, which is exactly what a caller sweeping ids is asking.","parameters":[{"schema":{"type":"string","description":"Project id. An id from another organization reads as absent."},"required":true,"description":"Project id. An id from another organization reads as absent.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`prj_` followed by a 16 character id."},"org_id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string","description":"Unique within the organization."},"is_default":{"type":"boolean","description":"At most one project per organization carries `true`, held by a partial unique index on the table. At LEAST one is not guaranteed by any constraint, and nothing in this API creates one: a project created through this API is always `false`, and the organization's first project is seeded elsewhere."},"environment":{"type":"string","enum":["live","test"],"description":"Fixed at creation. There is no field to change it afterwards."},"created_at":{"type":"string","format":"date-time"}},"required":["id","org_id","name","slug","is_default","environment","created_at"]}}}},"404":{"description":"`not_found`. Also the answer for an id owned by another organization.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"patch":{"summary":"Rename a project, change its slug, or promote it to default","description":"At least one field must be present; an empty body is refused with 400.\n\nROLE. This is an administrative operation and it carries the `admin` role gate. On a credential of the kind this document describes — an API key or an OAuth access token — that gate does NOT refuse today. It resolves the user forwarded in the `x-codespar-user` header against this organization's members, records the outcome when no user was forwarded, the user is not a member of the organization, or the user ranks below `admin`, and then lets the request through. Refusal is behind a server-side rollout flag that is currently off. Forward an `admin` or `owner` user in `x-codespar-user` now: when the flag is turned on, a call that does not carry one starts being answered with 403 instead of being served.\n\nSCOPE. A key created with an explicit scope list needs `projects:write` and is answered with 403 without it. A wildcard key passes: the wildcard is what a key gets when it is created without an explicit list, which is every key by default.\n\n`is_default` accepts `true` and nothing else. A project cannot be un-defaulted, and the only way to move the default is to promote a different project: the previous default is cleared and the new one set in one transaction, so no committed state ever shows two defaults. Promoting a project that is already the default is a no-op.\n\n`environment` is absent on purpose and cannot be changed here or anywhere else. A cross-tenant id is 404, on the same reasoning as the read.\n\nTWO THINGS ARE ANSWERED WITH `slug_conflict`, and only one of them is about a slug. A slug already taken in the organization is 400 `slug_conflict` with `details.slug` carrying the value you sent. A patch that names NO slug can also get `slug_conflict`, and then `details.slug` is null — but the collision there is not a slug at all: the only other unique index on the table is the partial one that permits a single default per organization, so what you hit is two promotions racing. The handler labels both the same way. Read a null `details.slug` as `retry the promotion`, not as a slug collision to go hunting for.\n\nThe 200 body is the project as it stands after the update.","parameters":[{"schema":{"type":"string","description":"Project id. An id from another organization reads as absent."},"required":true,"description":"Project id. An id from another organization reads as absent.","name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":128},"slug":{"type":"string","minLength":1,"maxLength":64,"pattern":"^[a-z0-9_-]+$"},"is_default":{"type":"boolean","enum":[true]}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`prj_` followed by a 16 character id."},"org_id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string","description":"Unique within the organization."},"is_default":{"type":"boolean","description":"At most one project per organization carries `true`, held by a partial unique index on the table. At LEAST one is not guaranteed by any constraint, and nothing in this API creates one: a project created through this API is always `false`, and the organization's first project is seeded elsewhere."},"environment":{"type":"string","enum":["live","test"],"description":"Fixed at creation. There is no field to change it afterwards."},"created_at":{"type":"string","format":"date-time"}},"required":["id","org_id","name","slug","is_default","environment","created_at"]}}}},"400":{"description":"`invalid_body` when the patch is empty or a field fails validation (`details.issues`). `slug_conflict` when the slug is taken, or when a concurrent promotion collided (`details.slug` null).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body","slug_conflict"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"`not_found`. Also the answer for an id owned by another organization.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"delete":{"summary":"Delete a project","description":"A hard delete, not a soft one: the project row is removed and most of what hangs off it goes with it. 204 with no body on success.\n\nROLE. This is an administrative operation and it carries the `admin` role gate. On a credential of the kind this document describes — an API key or an OAuth access token — that gate does NOT refuse today. It resolves the user forwarded in the `x-codespar-user` header against this organization's members, records the outcome when no user was forwarded, the user is not a member of the organization, or the user ranks below `admin`, and then lets the request through. Refusal is behind a server-side rollout flag that is currently off. Forward an `admin` or `owner` user in `x-codespar-user` now: when the flag is turned on, a call that does not carry one starts being answered with 403 instead of being served.\n\nSCOPE. A key created with an explicit scope list needs `projects:write` and is answered with 403 without it. A wildcard key passes: the wildcard is what a key gets when it is created without an explicit list, which is every key by default.\n\nThree refusals, all 409 because the request is well formed and conflicts with an invariant the organization relies on:\n\n- `cannot_delete_default`: promote another project to default first.\n- `cannot_delete_last_project`: an organization always keeps at least one project for new resources to attach to.\n- `cannot_delete_with_consumer_records`: the project still owns consumer records, which are kept rather than dragged along by the delete.\n\nAll three carry `details.project_id`. The third also carries `details.blocked_by`, a list of `{ table, rows }`. A pre-flight probe names EVERY blocking table with its row count, so one refusal tells you everything to clear rather than making you rediscover the next blocker on each retry. A record inserted between that probe and the delete is caught by a backstop that can only name the one table that raised, and reports `rows: null` for it; an empty `blocked_by` means even that table was not identifiable. Nothing in this API deletes those records for you.\n\nA cross-tenant id is 404, not 403.","parameters":[{"schema":{"type":"string","description":"Project id. An id from another organization reads as absent."},"required":true,"description":"Project id. An id from another organization reads as absent.","name":"id","in":"path"}],"responses":{"204":{"description":"No Content"},"404":{"description":"`not_found`. Also the answer for an id owned by another organization.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"The delete conflicts with an invariant. `details.project_id` on all three; `details.blocked_by` lists `{ table, rows }` on the consumer-records refusal.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["cannot_delete_default","cannot_delete_last_project","cannot_delete_with_consumer_records"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/projects/{id}/settings":{"get":{"summary":"Read a project's effective settings","description":"Every setting declared for the project's environment, with its effective value, the declared default, and whether the value was set explicitly or is still tracking that default. `explicit: false` means the key will follow the default if the default ever moves.\n\nSCOPE. A key created with an explicit scope list needs `projects:settings` and is answered with 403 without it. A wildcard key passes: the wildcard is what a key gets when it is created without an explicit list, which is every key by default.\n\nThis read alone carries NO role gate. The write and the history read below both do, and what that gate currently does is described on each of them.\n\nThe list is filtered by environment: a setting declared test-only does not appear on a live project at all, so an empty list is a normal answer rather than an error. A stored value whose key is no longer declared is omitted too. Every setting the registry declares as this is written applies to `test` only, which makes an empty list what a `live` project returns — a fact about the current declarations, not about this route.\n\nA cross-tenant id is 404.","parameters":[{"schema":{"type":"string","description":"Project id. An id from another organization reads as absent."},"required":true,"description":"Project id. An id from another organization reads as absent.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"settings":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string","description":"Stable, never recycled or re-pointed at another meaning."},"type":{"type":"string","enum":["boolean","string","enum","number"]},"description":{"type":"string"},"value":{"description":"The effective value: the explicit one when set, else `default`."},"default":{"description":"The declared default."},"explicit":{"type":"boolean","description":"false means the key is still tracking `default` and will follow it if it moves."},"freshness":{"type":"string","enum":["request","operation"],"description":"`operation` means consult sites re-read the value per operation."},"payee_affecting":{"type":"boolean","description":"True when the value names a funds destination. Those are test-only."},"enum_values":{"type":"array","items":{"type":"string"},"description":"Present only when `type` is `enum`."}},"required":["key","type","description","explicit","freshness","payee_affecting"]}}},"required":["settings"]}}}},"404":{"description":"`not_found`. Also the answer for an id owned by another organization.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"patch":{"summary":"Set or reset a project's settings","description":"The body is `{ \"settings\": { <key>: <value> } }`. A `null` value RESETS that key to default-tracking; it does not store null. Multiple keys in one call are applied together.\n\nROLE. This is an administrative operation and it carries the `admin` role gate. On a credential of the kind this document describes — an API key or an OAuth access token — that gate does NOT refuse today. It resolves the user forwarded in the `x-codespar-user` header against this organization's members, records the outcome when no user was forwarded, the user is not a member of the organization, or the user ranks below `admin`, and then lets the request through. Refusal is behind a server-side rollout flag that is currently off. Forward an `admin` or `owner` user in `x-codespar-user` now: when the flag is turned on, a call that does not carry one starts being answered with 403 instead of being served.\n\nSCOPE. A key created with an explicit scope list needs `projects:settings` and is answered with 403 without it. A wildcard key passes: the wildcard is what a key gets when it is created without an explicit list, which is every key by default.\n\nThe whole batch is validated against the settings registry before anything is written, so one bad entry rejects the entire call and nothing changes. An unknown key, a value of the wrong type, or a key that does not apply to the project's environment is 400 `invalid_body` with `details.key` naming the offender. An empty `settings` object is 400 as well, with no `details`.\n\nEach key that actually changes appends one audit entry in the same transaction as the write, so the change and its record cannot come apart.\n\nREPEATING A CALL IS NOT ALWAYS FREE, and the asymmetry is worth reading before you build a retry on it. A reset of a key already tracking its default is a true no-op: nothing is written and nothing is recorded. A WRITE is not symmetric with that. The shortcut that skips a write applies only to a key that is ALREADY explicit and already holds the value you sent. On a key still tracking its default — the state every key is in on a fresh project — sending exactly the default's own value IS a change: the key becomes explicit, it appears in the stored blob, and one audit entry is appended whose `old_value` and `new_value` are equal. The SECOND identical call is the one that does nothing. So a retry that looks idempotent can still be the call that flips a key off default-tracking, which matters because an explicit key stops following the default if the default later moves.\n\nThe 200 body is the same shape the read returns: the effective settings AFTER the write, not only the keys this call touched.\n\nA cross-tenant id is 404.","parameters":[{"schema":{"type":"string","description":"Project id. An id from another organization reads as absent."},"required":true,"description":"Project id. An id from another organization reads as absent.","name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"settings":{"type":"object","additionalProperties":{}}},"required":["settings"]}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"settings":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string","description":"Stable, never recycled or re-pointed at another meaning."},"type":{"type":"string","enum":["boolean","string","enum","number"]},"description":{"type":"string"},"value":{"description":"The effective value: the explicit one when set, else `default`."},"default":{"description":"The declared default."},"explicit":{"type":"boolean","description":"false means the key is still tracking `default` and will follow it if it moves."},"freshness":{"type":"string","enum":["request","operation"],"description":"`operation` means consult sites re-read the value per operation."},"payee_affecting":{"type":"boolean","description":"True when the value names a funds destination. Those are test-only."},"enum_values":{"type":"array","items":{"type":"string"},"description":"Present only when `type` is `enum`."}},"required":["key","type","description","explicit","freshness","payee_affecting"]}}},"required":["settings"]}}}},"400":{"description":"`invalid_body`: the body did not match the schema (`details.issues`), the patch was empty, or a key failed the registry check (`details.key`).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"`not_found`. Also the answer for an id owned by another organization.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/projects/{id}/settings/history":{"get":{"summary":"Read a project's settings audit log","description":"The ordered change history for this project's settings, oldest first.\n\nROLE. This is an administrative operation and it carries the `admin` role gate. On a credential of the kind this document describes — an API key or an OAuth access token — that gate does NOT refuse today. It resolves the user forwarded in the `x-codespar-user` header against this organization's members, records the outcome when no user was forwarded, the user is not a member of the organization, or the user ranks below `admin`, and then lets the request through. Refusal is behind a server-side rollout flag that is currently off. Forward an `admin` or `owner` user in `x-codespar-user` now: when the flag is turned on, a call that does not carry one starts being answered with 403 instead of being served.\n\nSCOPE. A key created with an explicit scope list needs `projects:settings` and is answered with 403 without it. A wildcard key passes: the wildcard is what a key gets when it is created without an explicit list, which is every key by default.\n\nOne entry per key that actually changed, so a call that wrote a value an already-explicit key was already holding leaves no trace here. Note what does leave a trace: writing a key's own default value while it is still tracking that default is a change, and it lands here with `old_value` equal to `new_value`. `explicit_before` and `explicit_after` are what separate a reset from a write: a reset ends with `explicit_after: false` and a `new_value` equal to the declared default.\n\nCapped at the 200 OLDEST entries and not paginated. Read that carefully: the order is ascending and the limit is applied after it, so once a project has recorded more than 200 changes this endpoint stops showing the most RECENT ones. There is no cursor and no limit parameter to page past it.\n\n`created_at` on each entry is not a parseable RFC 3339 timestamp; see the field's own description.\n\nA cross-tenant id is 404.","parameters":[{"schema":{"type":"string","description":"Project id. An id from another organization reads as absent."},"required":true,"description":"Project id. An id from another organization reads as absent.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"history":{"type":"array","items":{"type":"object","properties":{"setting_key":{"type":"string"},"actor":{"type":"string","description":"The user forwarded in `x-codespar-user` when there was one, else the id of the key that wrote, else `system`."},"old_value":{"description":"The effective value before the change."},"new_value":{"description":"The effective value after it."},"explicit_before":{"type":"boolean"},"explicit_after":{"type":"boolean","description":"false after a reset: the key went back to tracking its default."},"created_at":{"type":"string","description":"NOT RFC 3339, and it will not parse. The server renders the UTC offset without minutes when they are zero — `2026-09-08T12:34:56.789+00`, not `+00:00` — and `new Date()` on that string yields `Invalid Date`. Treat it as opaque, or append `:00` to a two-character offset before parsing."}},"required":["setting_key","actor","explicit_before","explicit_after","created_at"]}}},"required":["history"]}}}},"404":{"description":"`not_found`. Also the answer for an id owned by another organization.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/sellers":{"post":{"summary":"Onboard a seller (KYB) and affiliate it at Rinne","description":"Creates the sell-side counterparty. The body mirrors Rinne's `create_merchant` input: `full_name`, `document_number`, `document_type`, `document_tax_type`, `mcc`, `contact`, `address` and `transfer_configurations` are required, the rest are optional. `contact` and `address` are passed through as free objects and are not validated field by field here.\n\nIDEMPOTENT ON THE DOCUMENT, and this is the field an integrator most needs to know. Before anything is created the flow searches Rinne for a merchant with the same document and for an existing active affiliation, and the local row is written with `ON CONFLICT (org_id, project_id, document_number)` that reuses the SAME `seller_id` instead of minting a second one. A repeat call therefore answers 201 again, with the id you already had. The whole flow is serialized per (org, project, document) under an advisory lock, so two concurrent calls cannot both decide the merchant is new.\n\n`linked_consumer_id` is set on first write and then kept: a later call that sends a different one does not overwrite it. The other KYB fields are overwritten by the re-POST.\n\n`document_number` is stored with every non-digit stripped. A value that survives the schema but contains no digits at all leaves nothing to store, and that surfaces as 502 `seller_document_required`, not as 400: the check lives in the provisioning flow, past the point where the body was validated.\n\nThe response is the seller row, not Rinne's. `status` and `affiliation_status` reflect whatever the affiliation was at that instant, which for a fresh onboarding is normally pending rather than active.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"full_name":{"type":"string","minLength":1},"name":{"type":"string"},"document_number":{"type":"string","minLength":1},"document_type":{"type":"string","enum":["CNPJ","CPF"]},"document_tax_type":{"type":"string","enum":["PJ","MEI","ME","PF"]},"mcc":{"type":"string","minLength":1},"contact":{"type":"object","additionalProperties":{}},"address":{"type":"object","additionalProperties":{}},"transfer_configurations":{"type":"object","properties":{"automatic_transfer_enabled":{"type":"boolean"},"transfer_frequency":{"type":"string","enum":["DAILY","WEEKLY","MONTHLY"]},"rail":{"type":"string","enum":["PIX"]},"utc_hour_of_day":{"type":"integer"},"day_of_week":{"type":"integer"},"day_of_month":{"type":"integer"},"min_balance":{"type":"integer"}},"required":["automatic_transfer_enabled","transfer_frequency","rail"]},"declared_revenue":{"type":"number"},"declared_income":{"type":"number"},"net_worth":{"type":"number"},"company_logo_url":{"type":"string"},"website_url":{"type":"string"},"fee_policy_id":{"type":"string"},"metadata":{"type":"object","additionalProperties":{}},"linked_consumer_id":{"type":"string"},"allowed_capture_methods":{"type":"array","items":{"type":"string"}},"allowed_payment_methods":{"type":"array","items":{"type":"string"}}},"required":["full_name","document_number","document_type","document_tax_type","mcc","contact","address","transfer_configurations"]}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"seller_id":{"type":"string","description":"`slr_` + nanoid. Stable across a re-POST of the same document."},"status":{"type":"string","description":"CodeSpar's normalized lifecycle, derived from `affiliation_status` by `deriveSellerStatus`. A CHECK constraint on the column admits exactly five values — `pending`, `affiliation_pending`, `active`, `rejected`, `failed` — and a write outside that set fails rather than being stored. Three of the five are what this API produces: `ACTIVE` maps to `active`, `REJECTED` to `rejected`, and every other Rinne string, recognized or not, to `affiliation_pending` rather than to a failure state. `pending` is the column default and no onboarding write leaves it in place; nothing writes `failed`."},"affiliation_status":{"type":"string","description":"Rinne's own status string, stored verbatim (for example `PENDING_APPROVAL`, `ACTIVE`, `REJECTED`). This is the column with NO CHECK constraint, and that is deliberate: a status Rinne introduces later is stored as it came instead of turning into a hard write failure. Treat it as an open set."},"rinne_merchant_id":{"type":["string","null"]},"rinne_affiliation_id":{"type":["string","null"]},"document_number":{"type":"string","description":"Digits only. Everything non-numeric is stripped before the row is written."},"linked_consumer_id":{"type":["string","null"]},"full_name":{"type":"string"},"wallet_id":{"type":["string","null"],"description":"The BRL wallet this seller's settlements credit. Null until the first settlement lands, because the wallet is provisioned lazily on that credit."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"required":["seller_id","status","affiliation_status","rinne_merchant_id","rinne_affiliation_id","document_number","linked_consumer_id","full_name","wallet_id","created_at","updated_at"]}}}},"400":{"description":"Bad Request — the body or query did not match the schema.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body","invalid_query"]},"issues":{"type":"array","items":{},"description":"Zod issues, when the route reports them"}},"required":["error"]}}}},"502":{"description":"Bad Gateway. Provisioning did not complete. `error` carries the failing step's code when the throw had one (for example `rinne_create_merchant_failed`, `rinne_merchant_ambiguous`, `rinne_credentials_unavailable`, `seller_document_required`) and `seller_provisioning_failed` otherwise. The code set is not closed: the Rinne codes are built from the call's action name. Nothing was written locally when this is returned.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"message":{"type":"string"}},"required":["error","message"]}}}}}}},"/v1/sellers/{sellerId}":{"get":{"summary":"Read one seller","description":"The stored row, with no call to Rinne. Use `/status` instead when the answer has to be current rather than last known.\n\nScoped to the caller's org AND project, so a seller onboarded under a different project of the same organization answers 404 here.","parameters":[{"schema":{"type":"string"},"required":true,"name":"sellerId","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"seller_id":{"type":"string","description":"`slr_` + nanoid. Stable across a re-POST of the same document."},"status":{"type":"string","description":"CodeSpar's normalized lifecycle, derived from `affiliation_status` by `deriveSellerStatus`. A CHECK constraint on the column admits exactly five values — `pending`, `affiliation_pending`, `active`, `rejected`, `failed` — and a write outside that set fails rather than being stored. Three of the five are what this API produces: `ACTIVE` maps to `active`, `REJECTED` to `rejected`, and every other Rinne string, recognized or not, to `affiliation_pending` rather than to a failure state. `pending` is the column default and no onboarding write leaves it in place; nothing writes `failed`."},"affiliation_status":{"type":"string","description":"Rinne's own status string, stored verbatim (for example `PENDING_APPROVAL`, `ACTIVE`, `REJECTED`). This is the column with NO CHECK constraint, and that is deliberate: a status Rinne introduces later is stored as it came instead of turning into a hard write failure. Treat it as an open set."},"rinne_merchant_id":{"type":["string","null"]},"rinne_affiliation_id":{"type":["string","null"]},"document_number":{"type":"string","description":"Digits only. Everything non-numeric is stripped before the row is written."},"linked_consumer_id":{"type":["string","null"]},"full_name":{"type":"string"},"wallet_id":{"type":["string","null"],"description":"The BRL wallet this seller's settlements credit. Null until the first settlement lands, because the wallet is provisioned lazily on that credit."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"required":["seller_id","status","affiliation_status","rinne_merchant_id","rinne_affiliation_id","document_number","linked_consumer_id","full_name","wallet_id","created_at","updated_at"]}}}},"404":{"description":"Not Found. No seller with that id under the caller's org and project. A seller that exists in another tenant answers identically, so a 404 here is not evidence that the id was never minted.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["seller_not_found"]}},"required":["error"]}}}}}}},"/v1/sellers/{sellerId}/status":{"get":{"summary":"Refresh a seller's affiliation status from Rinne","description":"Reads the affiliation at Rinne and, when the status changed, writes the new one to the row before answering. On-demand, not a scheduled sweep.\n\nDEGRADES RATHER THAN FAILING, and says so. When the Rinne read does not land the last known row is still returned with 200, carrying `stale: true` and a `refresh_error`. Read those two before trusting `status`: a 200 here is not by itself a confirmation. A seller with nothing at Rinne yet (both provider ids null) is not stale, because there is no upstream status for it to lag, so it comes back with `refreshed_at: null` and `refresh_error: null`.\n\nThe 502 is the narrower case where the call threw outright, for instance because no Rinne credential could be resolved. A non-ok response from Rinne does not reach it; that is the stale path above.","parameters":[{"schema":{"type":"string"},"required":true,"name":"sellerId","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"seller_id":{"type":"string","description":"`slr_` + nanoid. Stable across a re-POST of the same document."},"status":{"type":"string","description":"CodeSpar's normalized lifecycle, derived from `affiliation_status` by `deriveSellerStatus`. A CHECK constraint on the column admits exactly five values — `pending`, `affiliation_pending`, `active`, `rejected`, `failed` — and a write outside that set fails rather than being stored. Three of the five are what this API produces: `ACTIVE` maps to `active`, `REJECTED` to `rejected`, and every other Rinne string, recognized or not, to `affiliation_pending` rather than to a failure state. `pending` is the column default and no onboarding write leaves it in place; nothing writes `failed`."},"affiliation_status":{"type":"string","description":"Rinne's own status string, stored verbatim (for example `PENDING_APPROVAL`, `ACTIVE`, `REJECTED`). This is the column with NO CHECK constraint, and that is deliberate: a status Rinne introduces later is stored as it came instead of turning into a hard write failure. Treat it as an open set."},"rinne_merchant_id":{"type":["string","null"]},"rinne_affiliation_id":{"type":["string","null"]},"document_number":{"type":"string","description":"Digits only. Everything non-numeric is stripped before the row is written."},"linked_consumer_id":{"type":["string","null"]},"full_name":{"type":"string"},"wallet_id":{"type":["string","null"],"description":"The BRL wallet this seller's settlements credit. Null until the first settlement lands, because the wallet is provisioned lazily on that credit."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"refreshed_at":{"type":["string","null"],"format":"date-time","description":"When the Rinne read that produced this row landed. Null when no read landed, which covers both a failed read and a seller with nothing at Rinne yet."},"refresh_error":{"type":["string","null"],"description":"Why the Rinne read did not land. Null on success and on a seller with nothing at Rinne yet."},"stale":{"type":"boolean","description":"Derived as `refresh_error !== null`, so it can never disagree with the field beside it. True means the body is the last known row, not what Rinne says right now."}},"required":["seller_id","status","affiliation_status","rinne_merchant_id","rinne_affiliation_id","document_number","linked_consumer_id","full_name","wallet_id","created_at","updated_at","refreshed_at","refresh_error","stale"]}}}},"404":{"description":"Not Found. No seller with that id under the caller's org and project. A seller that exists in another tenant answers identically, so a 404 here is not evidence that the id was never minted.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["seller_not_found"]}},"required":["error"]}}}},"502":{"description":"Bad Gateway. The status refresh threw before a row could be rendered. `error` is always `seller_status_failed`; the underlying reason is in `message`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["seller_status_failed"]},"message":{"type":"string"}},"required":["error","message"]}}}}}}},"/v1/sellers/{sellerId}/pending-settlement":{"get":{"summary":"What Rinne reports as sold but not yet settled","description":"A read-through view of the seller's Rinne statement, filtered to the movements explicitly marked unsettled. INFORMATIONAL ONLY: this endpoint never writes to the wallet balance or the wallet ledger. The credit happens later, when the settlement webhook reports the movement settled. Money listed here is coming, it is not spendable.\n\nREAD `unparsed_entries` BEFORE READING THE TOTAL. A movement whose `settled` field is not a real boolean is excluded rather than guessed, so an unrecognized statement shape produces a SMALLER total, not an error. When `unparsed_entries` is greater than zero, `total_pending_minor: \"0\"` means the statement was not understood, not that nothing is pending. The statement shape has not been verified against live Rinne traffic.\n\n409 is the not-yet case rather than a fault: the seller exists locally but has no Rinne merchant id, so there is nothing to ask about.","parameters":[{"schema":{"type":"string"},"required":true,"name":"sellerId","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"seller_id":{"type":"string"},"rinne_merchant_id":{"type":"string"},"total_pending_minor":{"type":"string","description":"Sum of `amount_minor` over the entries below. Informational only: it is not a balance, it is not spendable, and nothing here is ever posted to the wallet ledger."},"entries":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Empty string when the movement carried no id this parser recognized."},"amount_minor":{"type":["string","null"],"description":"BRL minor units. Null when the amount field was not recognized."},"currency":{"type":"string","description":"Uppercased when present, otherwise defaulted to `BRL`."},"type":{"type":["string","null"]},"operation":{"type":["string","null"]},"transaction_id":{"type":["string","null"]},"created_at":{"type":["string","null"],"description":"Rinne's own string, passed through unparsed. Not guaranteed to be ISO 8601."},"raw":{"type":"object","additionalProperties":{},"description":"The movement exactly as Rinne returned it."}},"required":["id","amount_minor","currency","type","operation","transaction_id","created_at","raw"]}},"raw_entries":{"type":"integer","description":"How many movements the statement returned, before any filtering."},"parsed_entries":{"type":"integer","description":"How many of those carried a `settled` field this code could read as a boolean."},"unparsed_entries":{"type":"integer","description":"`raw_entries` minus `parsed_entries`. Greater than zero means `total_pending_minor` understates what Rinne reported, so a total of `0` is not evidence that nothing is pending."}},"required":["seller_id","rinne_merchant_id","total_pending_minor","entries","raw_entries","parsed_entries","unparsed_entries"]}}}},"404":{"description":"Not Found. No seller with that id under the caller's org and project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["seller_not_found"]},"message":{"type":"string"}},"required":["error","message"]}}}},"409":{"description":"Conflict. The seller has no `rinne_merchant_id` yet, so it was never provisioned at Rinne and has no statement to read.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["seller_not_provisioned"]},"message":{"type":"string"}},"required":["error","message"]}}}},"502":{"description":"Bad Gateway. The statement read failed. `error` carries the failing step's code when the throw had one (for example `rinne_get_statement_failed`, `rinne_credentials_unavailable`) and `seller_pending_settlement_failed` otherwise. The code set is not closed.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"message":{"type":"string"}},"required":["error","message"]}}}}}}},"/v1/sellers/{sellerId}/custody":{"get":{"summary":"Compare this seller's ledger against Rinne's custody","description":"The pre-payout reconciliation: the wallet's available BRL against what Rinne reports holding for the bound merchant. Both sides are BRL MINOR units (centavos), never decimals and never the 6-decimal atomic units the USDC surface uses.\n\n`ledger_exceeds_custody` is the state to alarm on: the ledger shows more than Rinne holds, and spending against that balance can fail at payout. `unattributed_credit` is ordinary while a settlement webhook is in flight, and the pending-settlement read above is where to look next.\n\nA seller with no wallet yet compares as zero on the ledger side rather than erroring, so `unattributed_credit` on a never funded seller is expected. A seller with no Rinne merchant id answers 409 instead of comparing zero against zero and calling it reconciled.","parameters":[{"schema":{"type":"string"},"required":true,"name":"sellerId","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"seller_id":{"type":"string"},"rinne_merchant_id":{"type":"string"},"ledger_minor":{"type":"string","description":"What the wallet ledger says is available, BRL minor units (centavos)."},"rinne_minor":{"type":"string","description":"What Rinne reports as this merchant's balance, BRL minor units."},"difference_minor":{"type":"string","description":"`rinne_minor` minus `ledger_minor`. Signed and never clamped: the negative direction is the dangerous one and has to survive to the reader."},"state":{"type":"string","enum":["reconciled","unattributed_credit","ledger_exceeds_custody"],"description":"`reconciled` when the difference is zero. `unattributed_credit` when Rinne holds more than the ledger attributed, which is the ordinary state while a settlement webhook is in flight. `ledger_exceeds_custody` when the ledger shows more than Rinne holds, which is the state a payout can fail on."},"note":{"type":"string","description":"One sentence for the same three states, ready to render."}},"required":["seller_id","rinne_merchant_id","ledger_minor","rinne_minor","difference_minor","state","note"]}}}},"404":{"description":"Not Found. No seller with that id under the caller's org and project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["seller_not_found"]},"message":{"type":"string"}},"required":["error","message"]}}}},"409":{"description":"Conflict. The seller has no `rinne_merchant_id` yet, so there is no custody to compare against.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["seller_not_provisioned"]},"message":{"type":"string"}},"required":["error","message"]}}}},"502":{"description":"Bad Gateway. The balance read failed or could not be understood: `rinne_get_balance_failed` when Rinne answered non-ok, `rinne_get_balance_unparseable` when the response carried no balance field this code recognizes, `seller_custody_failed` as the fallback. The code set is not closed.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"message":{"type":"string"}},"required":["error","message"]}}}}}}},"/v1/sellers/{sellerId}/ledger":{"get":{"summary":"This seller's wallet ledger, without learning the wallet id","description":"A passthrough to the seller's own wallet ledger, scoped by its `wallet_id`, so a caller addresses the seller it already knows instead of the internal wallet. Same rows, same page shape and the same query as `GET /v1/wallets/{id}/ledger`. A settlement credit from Rinne lands here as `fund` and a reversed one as `reverse`; every other movement on the same wallet appears too, because this reads the whole ledger for that wallet and not a settlement-only view of it.\n\nNewest first. `next_before` is the id to send as `before_id` for the next page, and it is null on the last page. It is only non-null when the page came back exactly full, so a final page that happens to be full yields one more request that returns nothing.\n\nA seller that has never been settled has no wallet yet, and that is a normal state rather than an error: it answers 200 with an empty page. That short-circuit runs BEFORE the query is validated, so a malformed `limit` or `before_id` on such a seller also returns the empty page instead of 400.","parameters":[{"schema":{"type":"string"},"required":true,"name":"sellerId","in":"path"},{"schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","pattern":"^\\d+$"},"required":false,"name":"before_id","in":"query"},{"schema":{"type":"string","enum":["fund","hold","release","debit","reconcile","reverse","fee"]},"required":false,"name":"kind","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"entries":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"bigserial as string. This is also the `before_id` cursor."},"wallet_id":{"type":"string"},"org_id":{"type":"string"},"currency":{"type":"string","enum":["BRL","USD","MXN","COP","ARS","USDC","BRLA"]},"amount_minor":{"type":"string","description":"Signed minor units, as a string."},"kind":{"type":"string","enum":["fund","hold","release","debit","reconcile","reverse","fee"]},"mandate_id":{"type":["string","null"]},"attempt_id":{"type":["string","null"]},"external_ref":{"type":["string","null"]},"reconciled_at":{"type":["string","null"],"format":"date-time"},"posted_at":{"type":"string","format":"date-time"},"metadata":{"type":"object","additionalProperties":{}}},"required":["id","wallet_id","org_id","currency","amount_minor","kind","mandate_id","attempt_id","external_ref","reconciled_at","posted_at","metadata"]}},"next_before":{"type":["string","null"]}},"required":["entries","next_before"]}}}},"400":{"description":"Bad Request — the body or query did not match the schema.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body","invalid_query"]},"issues":{"type":"array","items":{},"description":"Zod issues, when the route reports them"}},"required":["error"]}}}},"404":{"description":"Not Found. No seller with that id under the caller's org and project. A seller that exists in another tenant answers identically, so a 404 here is not evidence that the id was never minted.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["seller_not_found"]}},"required":["error"]}}}}}}},"/v1/payment-links":{"post":{"summary":"Create a multi-rail payment link","description":"A shareable fixed-amount payment where the payment itself is the product, served at `pay_url`. One entry in `accepts` per rail, each with its own explicit price: there is NO FX between them, so the x402 amount and the pix amount are two independent prices and nothing reconciles them. A rail may appear at most once; a duplicate is refused with `invalid_rail`.\n\nAmounts go in as decimal strings and come back converted: x402 takes USDC with up to 6 decimals and returns `amount_atomic`, pix takes BRL with up to 2 decimals and returns `amount_minor`. Zero and negative are refused.\n\nFor x402, `pay_to.kind: \"byo\"` requires a 0x EVM address; `\"provisioned\"` requires `consumer_id` and provisions the receiving wallet during this request, which is why this write can answer 502. For pix, at least one of `pix.key` or `pix.celcoin_account` must be present.\n\n`slug` is optional and a 12-character lowercase alphanumeric one is generated when it is omitted. THE SLUG NAMESPACE IS GLOBAL, not per tenant, because it is the public gateway key: a 409 `slug_conflict` can therefore be caused by another organization's link, and retrying will not clear it. `environment` is taken from the calling key and cannot be set in the body. `metadata` is stored but is not echoed in any response on this surface.\n\nRole-gated: see the 403 below, and read it before assuming the gate runs for your credential.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"slug":{"type":"string","minLength":1,"maxLength":64,"pattern":"^[a-z0-9_-]+$"},"title":{"type":"string","minLength":1,"maxLength":200},"description":{"type":"string","maxLength":2000},"accepts":{"type":"array","items":{"oneOf":[{"type":"object","properties":{"rail":{"type":"string","enum":["x402"]},"amount":{"type":"string","minLength":1,"maxLength":32},"currency":{"type":"string","enum":["USDC"]},"pay_to":{"type":"object","properties":{"kind":{"type":"string","enum":["byo","provisioned"]},"address":{"type":"string","maxLength":128},"consumer_id":{"type":"string","minLength":1,"maxLength":128}},"required":["kind"]}},"required":["rail","amount","pay_to"]},{"type":"object","properties":{"rail":{"type":"string","enum":["pix"]},"amount":{"type":"string","minLength":1,"maxLength":32},"currency":{"type":"string","enum":["BRL"]},"pix":{"type":"object","properties":{"key":{"type":"string","minLength":1,"maxLength":140},"celcoin_account":{"type":"string","minLength":1,"maxLength":64}}}},"required":["rail","amount","pix"]}]},"minItems":1},"one_time":{"type":"boolean"},"max_uses":{"type":"integer","exclusiveMinimum":0},"expires_at":{"type":"string","format":"date-time"},"redirect_url":{"type":"string","maxLength":2048,"format":"uri"},"metadata":{"type":"object","additionalProperties":{}}},"required":["title","accepts"]}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`pl_` + nanoid."},"project_id":{"type":"string"},"slug":{"type":"string"},"title":{"type":"string"},"description":{"type":["string","null"]},"pay_url":{"type":"string","description":"`https://gw.codespar.dev/pay/<slug>`. Built from the slug, not stored."},"accepts":{"type":"array","items":{"oneOf":[{"type":"object","properties":{"rail":{"type":"string","enum":["x402"]},"currency":{"type":"string","enum":["USDC"]},"amount_atomic":{"type":"string","description":"USDC atomic units (6 decimals), as a string."},"pay_to":{"type":"string","description":"The resolved 0x receiving address."},"pay_to_kind":{"type":"string","enum":["byo","provisioned"]},"pay_to_account_name":{"type":["string","null"],"description":"The CDP account name. Present only when `pay_to_kind` is `provisioned`."},"consumer_id":{"type":["string","null"]}},"required":["rail","currency","amount_atomic","pay_to","pay_to_kind"]},{"type":"object","properties":{"rail":{"type":"string","enum":["pix"]},"currency":{"type":"string","enum":["BRL"]},"amount_minor":{"type":"integer","description":"BRL minor units (centavos), as a number."},"pix_key":{"type":["string","null"]},"celcoin_account":{"type":["string","null"]}},"required":["rail","currency","amount_minor"]}]}},"environment":{"type":"string","enum":["live","test"],"description":"Taken from the calling key, never from the request body."},"one_time":{"type":"boolean"},"status":{"type":"string","enum":["active","paid","expired","void"],"description":"Lifecycle, separate from `active`. `active` is the operator switch a PATCH flips; `status` is what the link's own use has made of it. A link is payable only when `active` is true, `status` is neither `void` nor `paid`, the expiry has not passed and the use cap has not been reached."},"use_count":{"type":"integer"},"max_uses":{"type":["integer","null"]},"expires_at":{"type":["string","null"],"format":"date-time"},"redirect_url":{"type":["string","null"]},"active":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"}},"required":["id","project_id","slug","title","description","pay_url","accepts","environment","one_time","status","use_count","max_uses","expires_at","redirect_url","active","created_at"]}}}},"400":{"description":"Bad Request. `invalid_body` when the body did not match the schema (the Zod issues are in `details.issues`) or when a rail's amount is not a valid positive amount for that rail. `invalid_rail` when a rail appears twice. `invalid_payto` when the payee is incomplete for the rail: a byo x402 without a 0x address, a provisioned x402 without `consumer_id`, or a pix with neither key nor account.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body","invalid_rail","invalid_payto"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"403":{"description":"Forbidden. This write is gated on an organization role, and the guard refused. It is reachable with an OAuth access token or a service credential: either no acting user was forwarded in the `x-codespar-user` header, or the user it named does not hold at least `admin` in the organization. Re-sending the same header will not help in the second case — promote that user, or forward one who already qualifies.\n\nAN API KEY IS NOT REFUSED HERE TODAY. The role requirement is not enforced for that credential at this version, so a key alone completes the write and a 200 is not evidence that anyone holding `admin` authorized it. Plan for the header: it is the arm that will be closed.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["insufficient_role"]},"required":{"type":"string","enum":["admin"]}},"required":["error","required"]}}}},"409":{"description":"Conflict. That slug is already taken. The namespace is global across tenants, so the holder may not be yours.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["slug_conflict"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"502":{"description":"Bad Gateway. A `provisioned` x402 payee could not be given a receiving wallet. Nothing was created; the whole link is refused rather than stored with one rail missing.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["payto_provisioning_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"get":{"summary":"List the payment links in this project","description":"Every link under the caller's org AND project, newest first. Not paginated and not filtered: there is no query parameter on this route, so a growing project returns a growing array.\n\nNote the asymmetry with the read-by-id below, which matches on the organization alone. A link created under a different project of the same organization is readable by its id and absent from this list.\n\nUnlike the three writes on this resource, the list carries no role gate at all.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"payment_links":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"`pl_` + nanoid."},"project_id":{"type":"string"},"slug":{"type":"string"},"title":{"type":"string"},"description":{"type":["string","null"]},"pay_url":{"type":"string","description":"`https://gw.codespar.dev/pay/<slug>`. Built from the slug, not stored."},"accepts":{"type":"array","items":{"oneOf":[{"type":"object","properties":{"rail":{"type":"string","enum":["x402"]},"currency":{"type":"string","enum":["USDC"]},"amount_atomic":{"type":"string","description":"USDC atomic units (6 decimals), as a string."},"pay_to":{"type":"string","description":"The resolved 0x receiving address."},"pay_to_kind":{"type":"string","enum":["byo","provisioned"]},"pay_to_account_name":{"type":["string","null"],"description":"The CDP account name. Present only when `pay_to_kind` is `provisioned`."},"consumer_id":{"type":["string","null"]}},"required":["rail","currency","amount_atomic","pay_to","pay_to_kind"]},{"type":"object","properties":{"rail":{"type":"string","enum":["pix"]},"currency":{"type":"string","enum":["BRL"]},"amount_minor":{"type":"integer","description":"BRL minor units (centavos), as a number."},"pix_key":{"type":["string","null"]},"celcoin_account":{"type":["string","null"]}},"required":["rail","currency","amount_minor"]}]}},"environment":{"type":"string","enum":["live","test"],"description":"Taken from the calling key, never from the request body."},"one_time":{"type":"boolean"},"status":{"type":"string","enum":["active","paid","expired","void"],"description":"Lifecycle, separate from `active`. `active` is the operator switch a PATCH flips; `status` is what the link's own use has made of it. A link is payable only when `active` is true, `status` is neither `void` nor `paid`, the expiry has not passed and the use cap has not been reached."},"use_count":{"type":"integer"},"max_uses":{"type":["integer","null"]},"expires_at":{"type":["string","null"],"format":"date-time"},"redirect_url":{"type":["string","null"]},"active":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"}},"required":["id","project_id","slug","title","description","pay_url","accepts","environment","one_time","status","use_count","max_uses","expires_at","redirect_url","active","created_at"]}}},"required":["payment_links"]}}}}}}},"/v1/payment-links/{id}":{"get":{"summary":"Read one payment link","description":"Addressed by id within the caller's ORGANIZATION: the lookup matches `id` and `org_id` and does not filter on the project, so a link belonging to another project of the same org is returned here. A link in another organization is invisible and answers 404, the same body as an id that was never minted.\n\n`metadata` is not part of this response. There is no operation on this surface that reads it back.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`pl_` + nanoid."},"project_id":{"type":"string"},"slug":{"type":"string"},"title":{"type":"string"},"description":{"type":["string","null"]},"pay_url":{"type":"string","description":"`https://gw.codespar.dev/pay/<slug>`. Built from the slug, not stored."},"accepts":{"type":"array","items":{"oneOf":[{"type":"object","properties":{"rail":{"type":"string","enum":["x402"]},"currency":{"type":"string","enum":["USDC"]},"amount_atomic":{"type":"string","description":"USDC atomic units (6 decimals), as a string."},"pay_to":{"type":"string","description":"The resolved 0x receiving address."},"pay_to_kind":{"type":"string","enum":["byo","provisioned"]},"pay_to_account_name":{"type":["string","null"],"description":"The CDP account name. Present only when `pay_to_kind` is `provisioned`."},"consumer_id":{"type":["string","null"]}},"required":["rail","currency","amount_atomic","pay_to","pay_to_kind"]},{"type":"object","properties":{"rail":{"type":"string","enum":["pix"]},"currency":{"type":"string","enum":["BRL"]},"amount_minor":{"type":"integer","description":"BRL minor units (centavos), as a number."},"pix_key":{"type":["string","null"]},"celcoin_account":{"type":["string","null"]}},"required":["rail","currency","amount_minor"]}]}},"environment":{"type":"string","enum":["live","test"],"description":"Taken from the calling key, never from the request body."},"one_time":{"type":"boolean"},"status":{"type":"string","enum":["active","paid","expired","void"],"description":"Lifecycle, separate from `active`. `active` is the operator switch a PATCH flips; `status` is what the link's own use has made of it. A link is payable only when `active` is true, `status` is neither `void` nor `paid`, the expiry has not passed and the use cap has not been reached."},"use_count":{"type":"integer"},"max_uses":{"type":["integer","null"]},"expires_at":{"type":["string","null"],"format":"date-time"},"redirect_url":{"type":["string","null"]},"active":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"}},"required":["id","project_id","slug","title","description","pay_url","accepts","environment","one_time","status","use_count","max_uses","expires_at","redirect_url","active","created_at"]}}}},"404":{"description":"Not Found. No payment link with that id in the caller's organization.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["payment_link_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"patch":{"summary":"Update a payment link's title, copy, expiry, redirect or active flag","description":"Five fields and no others: `title`, `description`, `active`, `expires_at`, `redirect_url`. Price, rails, slug, `one_time` and `max_uses` are NOT patchable. A body naming one of them has it STRIPPED IN SILENCE rather than refused, so a patch mixing a valid field with a non-patchable one applies the valid half and says nothing about the other. The case that does surface is a patch made entirely of non-patchable fields: it strips down to an empty object, and the empty-patch guard refuses that with 400. Repricing means creating another link.\n\nAn omitted field is left alone. `description`, `expires_at` and `redirect_url` additionally accept an explicit `null`, which CLEARS the column; the difference between absent and null is load bearing here.\n\n`active: false` takes the link out of service without deleting it, and is the reversible way to stop a live link. It does not change `status`.\n\nNARROWER THAN THE READ ABOVE, and this is the asymmetry to plan around. The statement names the row by `id` and `org_id`, but it runs under the calling key's PROJECT declaration, and payment links are tenant-owned at project grain — the row-level predicate the write is subject to also requires the row's project to be the caller's. A link that belongs to another project of the same organization is therefore readable by its id and not patchable with that key: it answers 404, because the update matched no row.\n\nRole-gated: see the 403 below, and read it before assuming the gate runs for your credential.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200},"description":{"type":["string","null"],"maxLength":2000},"active":{"type":"boolean"},"expires_at":{"type":["string","null"],"format":"date-time"},"redirect_url":{"type":["string","null"],"maxLength":2048,"format":"uri"}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"`pl_` + nanoid."},"project_id":{"type":"string"},"slug":{"type":"string"},"title":{"type":"string"},"description":{"type":["string","null"]},"pay_url":{"type":"string","description":"`https://gw.codespar.dev/pay/<slug>`. Built from the slug, not stored."},"accepts":{"type":"array","items":{"oneOf":[{"type":"object","properties":{"rail":{"type":"string","enum":["x402"]},"currency":{"type":"string","enum":["USDC"]},"amount_atomic":{"type":"string","description":"USDC atomic units (6 decimals), as a string."},"pay_to":{"type":"string","description":"The resolved 0x receiving address."},"pay_to_kind":{"type":"string","enum":["byo","provisioned"]},"pay_to_account_name":{"type":["string","null"],"description":"The CDP account name. Present only when `pay_to_kind` is `provisioned`."},"consumer_id":{"type":["string","null"]}},"required":["rail","currency","amount_atomic","pay_to","pay_to_kind"]},{"type":"object","properties":{"rail":{"type":"string","enum":["pix"]},"currency":{"type":"string","enum":["BRL"]},"amount_minor":{"type":"integer","description":"BRL minor units (centavos), as a number."},"pix_key":{"type":["string","null"]},"celcoin_account":{"type":["string","null"]}},"required":["rail","currency","amount_minor"]}]}},"environment":{"type":"string","enum":["live","test"],"description":"Taken from the calling key, never from the request body."},"one_time":{"type":"boolean"},"status":{"type":"string","enum":["active","paid","expired","void"],"description":"Lifecycle, separate from `active`. `active` is the operator switch a PATCH flips; `status` is what the link's own use has made of it. A link is payable only when `active` is true, `status` is neither `void` nor `paid`, the expiry has not passed and the use cap has not been reached."},"use_count":{"type":"integer"},"max_uses":{"type":["integer","null"]},"expires_at":{"type":["string","null"],"format":"date-time"},"redirect_url":{"type":["string","null"]},"active":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"}},"required":["id","project_id","slug","title","description","pay_url","accepts","environment","one_time","status","use_count","max_uses","expires_at","redirect_url","active","created_at"]}}}},"400":{"description":"Bad Request. The patch did not match the schema, which includes the empty patch and any field outside the five above. The Zod issues are in `details.issues`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_body"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"403":{"description":"Forbidden. This write is gated on an organization role, and the guard refused. It is reachable with an OAuth access token or a service credential: either no acting user was forwarded in the `x-codespar-user` header, or the user it named does not hold at least `admin` in the organization. Re-sending the same header will not help in the second case — promote that user, or forward one who already qualifies.\n\nAN API KEY IS NOT REFUSED HERE TODAY. The role requirement is not enforced for that credential at this version, so a key alone completes the write and a 200 is not evidence that anyone holding `admin` authorized it. Plan for the header: it is the arm that will be closed.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["insufficient_role"]},"required":{"type":"string","enum":["admin"]}},"required":["error","required"]}}}},"404":{"description":"Not Found. No payment link with that id reachable from this key: either no such id in the caller's organization, or one that lives in another of the organization's projects. The two are not distinguished.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["payment_link_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"delete":{"summary":"Delete a payment link","description":"A HARD delete of the row, not a soft one: the link is configuration rather than audit data, so nothing is retained and the operation cannot be undone. It touches only the `payment_links` row, and no foreign key points at that table, so nothing cascades. What is gone is the record of what the link was, including for payments already made through it.\n\nPrefer `PATCH { \"active\": false }` when the intent is to stop a live link, which is reversible and keeps the slug held. Deleting RELEASES the slug back into the global namespace, where anyone can take it.\n\nScoped like the PATCH and not like the read: the statement names the row by `id` and `org_id` and runs under the calling key's project declaration, so a link in another project of the same organization is not deletable with that key and answers 404.\n\nNot idempotent in its status code: a second delete of the same id answers 404, because the row count is what decides.\n\nRole-gated: see the 403 below, and read it before assuming the gate runs for your credential.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"No Content"},"403":{"description":"Forbidden. This write is gated on an organization role, and the guard refused. It is reachable with an OAuth access token or a service credential: either no acting user was forwarded in the `x-codespar-user` header, or the user it named does not hold at least `admin` in the organization. Re-sending the same header will not help in the second case — promote that user, or forward one who already qualifies.\n\nAN API KEY IS NOT REFUSED HERE TODAY. The role requirement is not enforced for that credential at this version, so a key alone completes the write and a 200 is not evidence that anyone holding `admin` authorized it. Plan for the header: it is the arm that will be closed.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["insufficient_role"]},"required":{"type":"string","enum":["admin"]}},"required":["error","required"]}}}},"404":{"description":"Not Found. No payment link with that id reachable from this key: either no such id in the caller's organization, or one that lives in another of the organization's projects. The two are not distinguished.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["payment_link_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/sessions/{id}/connections":{"get":{"summary":"List a session's servers and the tools it can call","description":"What this session may dispatch to. `servers` is the ids the session was opened on, enriched from the catalog; `tools` is the CodeSpar meta-tool set, which is catalog-wide rather than per-server, so every entry reports `server: \"codespar\"` — the routing layer, not a backend.\n\nFour of the six server fields are FALLBACKS, not facts, when the id is not in the catalog: `name` falls back to the id itself, `category` to `\"unknown\"`, `country` to `\"BR\"`, and `auth_type` to `\"api_key\"`. A `BR`/`api_key` pair can therefore mean either \"this is a Brazilian API-key server\" or \"nothing is known about this id\", and the two are not distinguishable from this response.\n\n`connected` is `false` on every entry today. It is a placeholder, not an auth-state read: do not treat `false` as evidence that a credential is missing.\n\nA session in another project is 404, the same answer as one that never existed.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"servers":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"category":{"type":"string"},"country":{"type":"string"},"auth_type":{"type":"string"},"connected":{"type":"boolean"}},"required":["id","name","category","country","auth_type","connected"]}},"tools":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"},"input_schema":{"type":"object","properties":{"type":{"type":"string","enum":["object"]},"properties":{"type":"object","additionalProperties":{}},"required":{"type":"array","items":{"type":"string"}}},"required":["type","properties"]},"server":{"type":"string","enum":["codespar"]}},"required":["name","description","input_schema","server"]}}},"required":["servers","tools"]}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}}},"/v1/sessions/{id}/mocks":{"get":{"summary":"Read a session's declared mocks and their consume counters","description":"`mocks` is the session's declared mock document verbatim, or `null` when the session declared none. `counters` is keyed by canonical tool name: `n` is how many times that tool has been consumed, `cap` is how many the declaration allows.\n\n`cap` is DERIVED, not stored: an array entry caps at its length; an object entry is uncapped and reports `null`. A tool with a counter row but no declared entry also reports `null`, so `cap: null` means \"uncapped or undeclared\", never \"zero remaining\".\n\nReading this is not gated by environment. DECLARING mocks is: opening a session with a `mocks` field on a live-environment project is refused there, not here. When `n` reaches `cap`, the next dispatch of that tool fails with 422 `mocks_exhausted` on `POST /v1/sessions/{id}/execute` rather than falling through to a real provider.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"mocks":{"type":["object","null"],"additionalProperties":{}},"counters":{"type":"object","additionalProperties":{"type":"object","properties":{"n":{"type":"integer"},"cap":{"type":["integer","null"]}},"required":["n","cap"]}}},"required":["mocks","counters"]}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}}}}},"/v1/sessions/{id}/execute":{"post":{"summary":"Run one tool in the session","description":"Resolves `tool` as a CodeSpar meta-tool first, then as a catalog tool, and logs the call to the session's audit chain either way.\n\nA FAILURE IS USUALLY A 200. `success: false` with an `error` string is the normal envelope for a tool that ran and did not work; the HTTP codes below are for calls that never reached a tool at all. An unregistered `tool` is also a 200, and its envelope is SHORTER — no `tool_call_id`, no `called_at`, `server` an empty string — because nothing was logged.\n\n`data` IS NOT NULL ON FAILURE. On a catalog tool whose upstream answered non-2xx, `data` carries the upstream's own error body; on a strategy refusal it carries `{ error, code, details? }`. Reading `data === null` as \"it failed\" throws away the body that explains the failure. Read `success`.\n\nThree refusals arrive IN BAND on the catalog branch, as a 200 whose `error` starts with `policy_denied: `, `approval_required: ` or `policy_engine_error: `. The policy 403 and the policy 503 below are the same decisions taken one layer earlier, by the route's guard, before the handler runs. A caller that only checks the status code will read an in-band refusal as a successful request.\n\n413 is absent from this operation on purpose: the guard here does not hand the caller's tool input to the policy engine, so the engine's payload cap cannot fire. `POST /v1/sessions/{id}/proxy_execute` does hand it over, and does answer 413.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"tool":{"type":"string","minLength":1},"params":{"type":"object","additionalProperties":{}},"input":{"type":"object","additionalProperties":{}},"estimatedCost":{"type":"number","minimum":0}},"required":["tool"]}}}},"responses":{"200":{"description":"OK. The full envelope, or the short unregistered-tool envelope that carries no `tool_call_id` and no `called_at`.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"success":{"type":"boolean"},"data":{"description":"The tool's output. On failure this is the upstream error body or the refusal payload, not null."},"error":{"type":["string","null"]},"duration":{"type":"number"},"server":{"type":"string"},"tool":{"type":"string"},"tool_call_id":{"type":"string"},"called_at":{"type":"string"}},"required":["success","error","duration","server","tool","tool_call_id","called_at"]},{"type":"object","properties":{"success":{"type":"boolean","enum":[false]},"data":{"type":"null"},"error":{"type":"string","description":"Starts with `Tool not registered: `."},"duration":{"type":"number","enum":[0]},"server":{"type":"string","enum":[""]},"tool":{"type":"string"}},"required":["success","data","error","duration","server","tool"]}]}}}},"400":{"description":"Bad Request — the body or query did not match the schema.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body","invalid_query"]},"issues":{"type":"array","items":{},"description":"Zod issues, when the route reports them"}},"required":["error"]}}}},"403":{"description":"Forbidden — either a policy rule refused the call, or the org's monthly tool-call allowance is spent. The two bodies are disjoint: the policy one is `{ reason, ruleType, ruleId }` and carries NO `error` key (plus `approval_id` and `expires_at` when the refusal opened a pending approval); the quota one is `{ error: \"quota_exceeded\", ... }`.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"reason":{"type":["string","null"],"description":"Why the rule refused, e.g. `deny_rule_matched`, `approval_required`."},"ruleType":{"type":["string","null"]},"ruleId":{"type":["string","null"]},"approval_id":{"type":"string","description":"Present only when the refusal wrote a pending approval row."},"expires_at":{"type":["string","null"]}},"required":["reason","ruleType","ruleId"]},{"type":"object","properties":{"error":{"type":"string","enum":["quota_exceeded"]},"plan":{"type":"string"},"limit":{"type":"number"},"used":{"type":"number"},"message":{"type":"string"}},"required":["error","plan","limit","used","message"]}]}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}},"409":{"description":"Conflict — the session is not `active`, so it dispatches nothing.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["session_not_active"]},"status":{"type":"string","description":"The status the session is actually in, e.g. `closed`."}},"required":["error","status"]}}}},"422":{"description":"Unprocessable — the session declares mocks and this tool has none left, or none at all. `tool_name` is present only on `tool_not_mocked`. Reached from the catalog branch; a meta-tool never answers 422.","content":{"application/json":{"schema":{"type":"object","properties":{"code":{"type":"string","enum":["mocks_exhausted","tool_not_mocked"]},"message":{"type":"string"},"tool_name":{"type":"string"}},"required":["code","message"]}}}},"503":{"description":"Service Unavailable — the route's policy guard could not answer (`{ error: \"policy_engine_error\" }`), or the mock engine failed (`{ code: \"mocks_engine_error\", message }`). Two different bodies at the same status.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"string","enum":["policy_engine_error"]}},"required":["error"]},{"type":"object","properties":{"code":{"type":"string","enum":["mocks_engine_error"]},"message":{"type":"string"}},"required":["code","message"]}]}}}}}}},"/v1/sessions/{id}/proxy_execute":{"post":{"summary":"Make one raw HTTP call to a connected server","description":"The escape hatch under the meta-tools: `method` + `endpoint` are forwarded to the server's upstream with credentials injected server-side. `status` is the UPSTREAM's status code, so a 200 here can carry a 404 from the provider; only a transport failure becomes 502.\n\n`server` and `endpoint` reject `:` because the policy engine canonicalises a tool name as `<server>:<method>:<endpoint>`, and a colon in either field would let a caller forge extra delimiters. That is a 400, not a 403: bad input, not a denied decision.\n\nMOCKED RESPONSES ANNOUNCE THEMSELVES. While a deployment answers from the mock lane, `headers` carries `x-codespar-mock: \"true\"` and `data` carries `mocked: true` sealed into the object itself. The second marker is the one that matters: it is also what the durable record and the audit chain store, so a mocked call cannot later be read as a real one.\n\n413 means the policy engine refused the tool input for size. The cap is `JSON.stringify(input).length > 1_000_000` — a count of UTF-16 code units of the serialized JSON, which is neither a byte count nor 1 MiB (1 048 576), and a payload of non-ASCII text hits it at fewer bytes than an ASCII one. The check runs only under a matched `approval-required` rule; with no such rule in play, size is not measured at all.\n\n424 appears only where the deployment is configured to require a resolved connection. Its `reason` is a closed set of three, which is every non-ok outcome the credential resolver can return.\n\n429 always carries `retry_after_ms`, and the same figure rounded up to whole seconds in the `Retry-After` header.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"server":{"type":"string","minLength":1,"maxLength":64},"endpoint":{"type":"string","minLength":1,"maxLength":2048},"method":{"type":"string","enum":["GET","POST","PUT","PATCH","DELETE"]},"body":{},"params":{"type":"object","additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"}]}},"headers":{"type":"object","additionalProperties":{"type":"string"}},"estimatedCost":{"type":"number","minimum":0}},"required":["server","endpoint","method"]}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"integer","description":"The UPSTREAM status code, not this call's."},"data":{},"headers":{"type":"object","additionalProperties":{"type":"string"}},"duration":{"type":"number"},"proxy_call_id":{"type":"string","description":"`px_`-prefixed id of the logged call."}},"required":["status","headers","duration","proxy_call_id"]}}}},"400":{"description":"Bad Request — the body did not match the schema, or `server` is not one of the ids this session was opened on. The second body names the connected servers and points at the pre-connected sandbox meta-tool instead.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body","invalid_query"]},"issues":{"type":"array","items":{}}},"required":["error"]},{"type":"object","properties":{"error":{"type":"string","enum":["server_not_connected"]},"server":{"type":"string"},"message":{"type":"string"},"hint":{"type":"string"},"alternative":{"type":"string"}},"required":["error","server","message","hint","alternative"]}]}}}},"403":{"description":"Forbidden — either a policy rule refused the call, or the org's monthly tool-call allowance is spent. The two bodies are disjoint: the policy one is `{ reason, ruleType, ruleId }` and carries NO `error` key (plus `approval_id` and `expires_at` when the refusal opened a pending approval); the quota one is `{ error: \"quota_exceeded\", ... }`.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"reason":{"type":["string","null"],"description":"Why the rule refused, e.g. `deny_rule_matched`, `approval_required`."},"ruleType":{"type":["string","null"]},"ruleId":{"type":["string","null"]},"approval_id":{"type":"string","description":"Present only when the refusal wrote a pending approval row."},"expires_at":{"type":["string","null"]}},"required":["reason","ruleType","ruleId"]},{"type":"object","properties":{"error":{"type":"string","enum":["quota_exceeded"]},"plan":{"type":"string"},"limit":{"type":"number"},"used":{"type":"number"},"message":{"type":"string"}},"required":["error","plan","limit","used","message"]}]}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}},"409":{"description":"Conflict — the session is not `active`, so it dispatches nothing.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["session_not_active"]},"status":{"type":"string","description":"The status the session is actually in, e.g. `closed`."}},"required":["error","status"]}}}},"413":{"description":"Payload Too Large — the serialized tool input exceeded the policy engine's cap under an `approval-required` rule. Not a policy denial: 403 and 413 are kept apart so a client can tell \"refused\" from \"too big\".","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["payload_too_large"]}},"required":["error"]}}}},"424":{"description":"Failed Dependency — no usable credential for this server. `reason` is the resolver's own outcome.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["credentials_unavailable"]},"reason":{"type":"string","enum":["server_unknown","not_connected","endpoint_missing"]},"server":{"type":"string"},"message":{"type":"string"}},"required":["error","reason","server","message"]}}}},"429":{"description":"Too Many Requests — the per-(org, server) bucket is empty. See the `Retry-After` header.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["rate_limited"]},"server":{"type":"string"},"retry_after_ms":{"type":"number"},"message":{"type":"string"}},"required":["error","server","retry_after_ms","message"]}}}},"502":{"description":"Bad Gateway — the upstream call itself failed (transport, not status). The attempt is still logged and chained, and `proxy_call_id` names the row.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["proxy_upstream_error"]},"message":{"type":"string"},"proxy_call_id":{"type":"string"}},"required":["error","message","proxy_call_id"]}}}},"503":{"description":"Service Unavailable — the policy engine could not answer. Fail-closed: the call did not run. Distinct from a 403, which is a decision that was taken.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["policy_engine_error"]}},"required":["error"]}}}}}}},"/v1/sessions/{id}/send":{"post":{"summary":"Drive a model loop that may call tools","description":"Sends one user message into a tool-use loop over the CodeSpar meta-tools, up to ten model turns. Content-negotiated: `Accept: text/event-stream` streams the turns as they happen and this JSON body is never sent; anything else gets the whole transcript at the end. The schema below describes the JSON form.\n\nWHAT `tool_calls` IS. Every entry is a call the loop attempted and logged, in order. It is NOT a list of provider calls: a refusal never reached a provider and still appears here. `server_id` is what separates them — a refusal by deny-list, policy or approval is logged under `agentgate`, a strategy error under `unknown`, a consumed mock under `mock`, and a real dispatch under the server's own id. Read `server_id` before treating an entry as money moved.\n\n`aborted: true` means the client hung up and the loop stopped rather than buying another turn; the transcript is short because it was cut, not because the model finished.\n\nA 500 is NOT a rollback. The loop broke part-way and everything that already ran stands: the same `tool_calls` and `iterations` fields ride on the error body, so a payment that settled on turn one is still visible when turn two died.\n\nThis route carries no policy guard of its own, so it has no policy 403 and no policy 503; the meta-tool refusals above are how a denial surfaces here. Its only 403 is the quota.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string","minLength":1,"maxLength":8000}},"required":["message"]}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string","description":"The model's last text turn. Empty when it ended on a tool call."},"tool_calls":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"tool_name":{"type":"string"},"server_id":{"type":"string"},"status":{"type":"string","enum":["success","error"]},"duration_ms":{"type":"number"},"input":{},"output":{},"error_code":{"type":["string","null"]},"routing":{},"failover_trail":{}},"required":["id","tool_name","server_id","status","duration_ms","error_code"]}},"iterations":{"type":"integer"},"aborted":{"type":"boolean","enum":[true]}},"required":["message","tool_calls","iterations"]}}}},"400":{"description":"Bad Request — the body or query did not match the schema.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_body","invalid_query"]},"issues":{"type":"array","items":{},"description":"Zod issues, when the route reports them"}},"required":["error"]}}}},"403":{"description":"Forbidden — the org's monthly tool-call allowance is spent.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["quota_exceeded"]},"plan":{"type":"string"},"limit":{"type":"number"},"used":{"type":"number"},"message":{"type":"string"}},"required":["error","plan","limit","used","message"]}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["not_found"]}},"required":["error"]}}}},"409":{"description":"Conflict — the session is not `active`, so it dispatches nothing.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["session_not_active"]},"status":{"type":"string","description":"The status the session is actually in, e.g. `closed`."}},"required":["error","status"]}}}},"500":{"description":"Internal Server Error — the loop failed mid-flight. Carries the work that already completed, in the same fields as the 200.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["chat_loop_failed"]},"message":{"type":"string"},"tool_calls":{"type":"array","items":{}},"iterations":{"type":"integer"}},"required":["error","message","tool_calls","iterations"]}}}},"503":{"description":"Service Unavailable — this deployment has no model credential configured.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["anthropic_not_configured"]}},"required":["error"]}}}}}}},"/v1/agents":{"post":{"summary":"Register an agent identity and mint its first signing key","description":"Creates a `did:web` identity for this org and returns its first key. The DID is DERIVED from the org and the `agent_id`, which is why both must be URL-safe: the accepted alphabet is letters, digits, `.`, `_` and `-`, and anything else is a 400 rather than a mangled identifier.\n\n`principal_ref` IS AN OPAQUE HANDLE, NEVER A DOCUMENT. This value is signed into a mandate and served to counterparties without auth. A run of eleven digits, punctuation ignored, is refused for that reason. Passing it is not proof of KYC either; verification is a separate record.\n\n`pubkey` is base64. The key material's private half never leaves the server and is not returned here or anywhere else.\n\nBoth 409s are caller-state, not server fault: the handle is already registered with an active key, or the derived DID belongs to another org's row. Registering under a different handle is the way out of either.\n\nErrors on this operation use the `{ error: { code, message }, request_id }` envelope, not the bare `{ error }` the session routes use.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"agent_id":{"type":"string","minLength":1},"display_name":{"type":"string","minLength":1},"principal_ref":{"type":"string","minLength":1}},"required":["agent_id","principal_ref"]}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"agent_did":{"type":"string"},"kid":{"type":"string","description":"`<did>#<n>`, where n is the key's ordinal."},"pubkey":{"type":"string","description":"base64."},"status":{"type":"string","enum":["active"]}},"required":["agent_did","kid","pubkey","status"]}}}},"400":{"description":"Bad Request — schema failure, or a DID segment outside the URL-safe alphabet.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["missing_required_field","org_id_not_did_safe","agent_id_not_did_safe"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"Conflict — the handle or the derived DID is taken.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["agent_already_registered","agent_did_owned_elsewhere"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/agents/{agentId}/keys/rotate":{"post":{"summary":"Mint a new signing key and retire the current one","description":"Retires the active key and mints a fresh one in the same transaction. `retired_kid` names what was retired.\n\nROTATION DOES NOT INVALIDATE THE PAST. A signature verifies when its key was active AT ISSUANCE and is not revoked NOW, so mandates signed before the retirement keep verifying against the retired key. That is the whole difference between retiring and revoking, and it is why rotating on a schedule is safe while revoking is not.\n\nThe new `kid`'s ordinal counts EVERY key row the identity has ever had, retired ones included: register gives `#1`, the first rotation `#2`. Ordinals are never reused, so a kid identifies one key for good.\n\n`{agentId}` is the handle, not the DID; the DID is rebuilt from the authenticated org. An agent registered by another org is therefore 404 here, never 403 — 403 would confirm that it exists.","parameters":[{"schema":{"type":"string"},"required":true,"name":"agentId","in":"path"}],"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"agent_did":{"type":"string"},"kid":{"type":"string"},"pubkey":{"type":"string","description":"base64."},"retired_kid":{"type":"string"},"status":{"type":"string","enum":["active"]}},"required":["agent_did","kid","pubkey","retired_kid","status"]}}}},"404":{"description":"Not Found — no such agent in this org.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["agent_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"Conflict — the agent has no active key to rotate.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["no_active_key"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/agents/{agentId}/keys/{kid}/revoke":{"post":{"summary":"Revoke one signing key","description":"Withdraws a key's authority. Unlike retirement, this is retroactive: a revoked key verifies nothing afterwards, including signatures it produced while it was active.\n\nIt also changes what counterparties can see. The published `did:web` document lists the identity's keys where status is not `revoked`, so a REVOKED key disappears from it while a RETIRED one stays and keeps verifying its own past issuance. Revoking is the verb for a key you believe is compromised; rotating is the verb for hygiene.\n\n`{kid}` is a full kid (`<did>#<n>`), and its `#` MUST be percent-encoded as `%23` so the whole kid arrives as one path segment.\n\nIdempotency is NOT offered: revoking an already-revoked key is 409, not a repeat of the 200.","parameters":[{"schema":{"type":"string"},"required":true,"name":"agentId","in":"path"},{"schema":{"type":"string","description":"A full kid; the `#` must be sent as `%23`."},"required":true,"description":"A full kid; the `#` must be sent as `%23`.","name":"kid","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"kid":{"type":"string"},"status":{"type":"string","enum":["revoked"]},"revoked_at":{"type":"string"}},"required":["kid","status","revoked_at"]}}}},"404":{"description":"Not Found — no such key on this org's agent.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["agent_key_not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"409":{"description":"Conflict — the key is already revoked.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["key_already_revoked"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/webhook-endpoints/{id}":{"get":{"summary":"Read one webhook endpoint","description":"The stored configuration of one webhook endpoint. Requires `triggers:read`.\n\nThe signing secret is never returned; `signing_enabled` reports only whether one exists. An id belonging to another organization, or to another project in the caller's own organization, answers 404 and not 403: both filters are in the WHERE clause, so the handler cannot tell absent from someone else's, and must not, because telling them apart confirms the id exists.","parameters":[{"schema":{"type":"string","description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call."},"required":true,"description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"org_id":{"type":"string"},"project_id":{"type":["string","null"],"description":"Nullable in the row type this response is serialized from; a later migration sets the column NOT NULL, so an endpoint created since then always carries one."},"name":{"type":"string"},"event":{"type":"string","description":"The event name this endpoint subscribes to, dot separated lowercase."},"server_id":{"type":["string","null"]},"webhook_url":{"type":"string"},"status":{"type":"string","description":"`active`, `paused` or `error`. Left as an open string because the column is `text` with no CHECK constraint: `active` and `paused` are what a patch may set, and `error` is what the dispatcher writes on its own when an endpoint auto-pauses after enough consecutive dead deliveries."},"total_runs":{"type":"integer","description":"Deliveries that landed. A failed or dead attempt does not count, which is why this can sit at 0 while the deliveries listing is full of rows."},"last_run_at":{"type":["string","null"],"format":"date-time","description":"Timestamp of the last DELIVERED attempt, on the same rule as `total_runs`."},"created_at":{"type":"string","format":"date-time"},"signing_enabled":{"type":"boolean","description":"Whether a signing secret exists. The secret itself is never read back: the serializer drops the vault reference and reports only this boolean."}},"required":["id","org_id","project_id","name","event","server_id","webhook_url","status","total_runs","last_run_at","created_at","signing_enabled"]}}}},"404":{"description":"No webhook endpoint with that id in the caller's organization and project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"delete":{"summary":"Delete a webhook endpoint","description":"Removes the endpoint and purges its HMAC signing key in the SAME transaction. Requires `triggers:write`. A hard delete, because an endpoint is configuration and not an audit record.\n\nThe delivery history goes with it. The delivery table's foreign key onto this one is declared ON DELETE CASCADE, so this call also removes every attempt and every dead letter recorded for the endpoint; read the deliveries you still need first.\n\nIt fails CLOSED. If the vault cannot purge the key, the whole transaction rolls back, the endpoint is still there, and the answer is 503 `vault_unavailable`. The alternative would leave live signing material in the vault for an object the customer believes is gone, with no route left that could read, rotate or remove it. Retry once the vault is reachable.\n\nThat 503 does not distinguish causes. Its catch is the widest on this surface: any failure inside the transaction, not only an unreachable vault, comes back under this one code. What it always means is that nothing was deleted.","parameters":[{"schema":{"type":"string","description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call."},"required":true,"description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call.","name":"id","in":"path"}],"responses":{"204":{"description":"No Content"},"404":{"description":"No webhook endpoint with that id in the caller's organization and project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"503":{"description":"The signing secret could not be purged, so nothing was deleted. Retriable.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["vault_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/triggers/{id}":{"get":{"deprecated":true,"externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979 - canonical path and alias window"},"summary":"Read one webhook endpoint (deprecated path)","description":"DEPRECATED alias of `GET /v1/webhook-endpoints/{id}` (ent#979), kept for two releases. Same handler, same required scope, same request and same responses; switch the path and nothing else changes. The canonical path is described in this document too.\n\nThe stored configuration of one webhook endpoint. Requires `triggers:read`.\n\nThe signing secret is never returned; `signing_enabled` reports only whether one exists. An id belonging to another organization, or to another project in the caller's own organization, answers 404 and not 403: both filters are in the WHERE clause, so the handler cannot tell absent from someone else's, and must not, because telling them apart confirms the id exists.","parameters":[{"schema":{"type":"string","description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call."},"required":true,"description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"org_id":{"type":"string"},"project_id":{"type":["string","null"],"description":"Nullable in the row type this response is serialized from; a later migration sets the column NOT NULL, so an endpoint created since then always carries one."},"name":{"type":"string"},"event":{"type":"string","description":"The event name this endpoint subscribes to, dot separated lowercase."},"server_id":{"type":["string","null"]},"webhook_url":{"type":"string"},"status":{"type":"string","description":"`active`, `paused` or `error`. Left as an open string because the column is `text` with no CHECK constraint: `active` and `paused` are what a patch may set, and `error` is what the dispatcher writes on its own when an endpoint auto-pauses after enough consecutive dead deliveries."},"total_runs":{"type":"integer","description":"Deliveries that landed. A failed or dead attempt does not count, which is why this can sit at 0 while the deliveries listing is full of rows."},"last_run_at":{"type":["string","null"],"format":"date-time","description":"Timestamp of the last DELIVERED attempt, on the same rule as `total_runs`."},"created_at":{"type":"string","format":"date-time"},"signing_enabled":{"type":"boolean","description":"Whether a signing secret exists. The secret itself is never read back: the serializer drops the vault reference and reports only this boolean."}},"required":["id","org_id","project_id","name","event","server_id","webhook_url","status","total_runs","last_run_at","created_at","signing_enabled"]}}}},"404":{"description":"No webhook endpoint with that id in the caller's organization and project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}},"delete":{"deprecated":true,"externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979 - canonical path and alias window"},"summary":"Delete a webhook endpoint (deprecated path)","description":"DEPRECATED alias of `DELETE /v1/webhook-endpoints/{id}` (ent#979), kept for two releases. Same handler, same required scope, same request and same responses; switch the path and nothing else changes. The canonical path is described in this document too.\n\nRemoves the endpoint and purges its HMAC signing key in the SAME transaction. Requires `triggers:write`. A hard delete, because an endpoint is configuration and not an audit record.\n\nThe delivery history goes with it. The delivery table's foreign key onto this one is declared ON DELETE CASCADE, so this call also removes every attempt and every dead letter recorded for the endpoint; read the deliveries you still need first.\n\nIt fails CLOSED. If the vault cannot purge the key, the whole transaction rolls back, the endpoint is still there, and the answer is 503 `vault_unavailable`. The alternative would leave live signing material in the vault for an object the customer believes is gone, with no route left that could read, rotate or remove it. Retry once the vault is reachable.\n\nThat 503 does not distinguish causes. Its catch is the widest on this surface: any failure inside the transaction, not only an unreachable vault, comes back under this one code. What it always means is that nothing was deleted.","parameters":[{"schema":{"type":"string","description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call."},"required":true,"description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call.","name":"id","in":"path"}],"responses":{"204":{"description":"No Content"},"404":{"description":"No webhook endpoint with that id in the caller's organization and project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"503":{"description":"The signing secret could not be purged, so nothing was deleted. Retriable.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["vault_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/webhook-endpoints/{id}/rotate-secret":{"post":{"summary":"Mint a new signing secret for a webhook endpoint","description":"Generates a new HMAC secret and overwrites the vault entry in place. Requires `triggers:write`. The handler reads no request body.\n\nTHERE IS NO OVERLAP WINDOW. The vault reference does not change, so the next dispatch that dereferences it signs with the new secret and the old one stops verifying the moment this transaction commits. Install the returned value in the subscriber before the next event fires, or those deliveries fail signature checks on the far side while still counting as delivered here.\n\nThe plaintext is revealed exactly once, in this response. The vault write and the row update share one transaction, so a failure leaves neither half standing and the answer is 503 `vault_unavailable` with no rotation performed: the previous secret keeps working.","parameters":[{"schema":{"type":"string","description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call."},"required":true,"description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"trigger_id":{"type":"string"},"signing_enabled":{"type":"boolean","enum":[true]},"secret":{"type":"string","description":"The new signing secret in plaintext, 32 random bytes as hex, shown EXACTLY ONCE. Later reads expose only `signing_enabled`, and a lost secret is replaced by rotating again."}},"required":["trigger_id","signing_enabled","secret"]}}}},"404":{"description":"No webhook endpoint with that id in the caller's organization and project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"503":{"description":"The new secret could not be persisted, so no rotation occurred and the previous secret is still in force. Retriable.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["vault_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/triggers/{id}/rotate-secret":{"post":{"deprecated":true,"externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979 - canonical path and alias window"},"summary":"Mint a new signing secret for a webhook endpoint (deprecated path)","description":"DEPRECATED alias of `POST /v1/webhook-endpoints/{id}/rotate-secret` (ent#979), kept for two releases. Same handler, same required scope, same request and same responses; switch the path and nothing else changes. The canonical path is described in this document too.\n\nGenerates a new HMAC secret and overwrites the vault entry in place. Requires `triggers:write`. The handler reads no request body.\n\nTHERE IS NO OVERLAP WINDOW. The vault reference does not change, so the next dispatch that dereferences it signs with the new secret and the old one stops verifying the moment this transaction commits. Install the returned value in the subscriber before the next event fires, or those deliveries fail signature checks on the far side while still counting as delivered here.\n\nThe plaintext is revealed exactly once, in this response. The vault write and the row update share one transaction, so a failure leaves neither half standing and the answer is 503 `vault_unavailable` with no rotation performed: the previous secret keeps working.","parameters":[{"schema":{"type":"string","description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call."},"required":true,"description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call.","name":"id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"trigger_id":{"type":"string"},"signing_enabled":{"type":"boolean","enum":[true]},"secret":{"type":"string","description":"The new signing secret in plaintext, 32 random bytes as hex, shown EXACTLY ONCE. Later reads expose only `signing_enabled`, and a lost secret is replaced by rotating again."}},"required":["trigger_id","signing_enabled","secret"]}}}},"404":{"description":"No webhook endpoint with that id in the caller's organization and project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"503":{"description":"The new secret could not be persisted, so no rotation occurred and the previous secret is still in force. Retriable.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["vault_unavailable"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/webhook-endpoints/{id}/deliveries":{"get":{"summary":"List recent delivery attempts","description":"Attempts for one endpoint, newest first by `created_at`. Requires `triggers:read`. The endpoint is loaded under the caller's organization and project first, so a delivery cannot be read across tenants; an unknown or foreign endpoint id is 404.\n\n`limit` is the ONLY control: there is no cursor and no total, so an attempt older than the 200 most recent is not reachable through this route. Each row is one attempt, not one event; a retried event appears once per attempt.","parameters":[{"schema":{"type":"string","description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call."},"required":true,"description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call.","name":"id","in":"path"},{"schema":{"type":"integer","default":50,"description":"Rows to return. Clamped into the range 1 to 200: a larger or smaller value is reduced or raised rather than refused, and a value that does not parse as a number falls back to 50."},"required":false,"description":"Rows to return. Clamped into the range 1 to 200: a larger or smaller value is reduced or raised rather than refused, and a value that does not parse as a number falls back to 50.","name":"limit","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"deliveries":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"bigint as string"},"trigger_id":{"type":"string"},"event_id":{"type":"string"},"attempt":{"type":"integer","description":"1 on the first dispatch. History is append only: a retry is a NEW row with `attempt + 1`, never an update of the row that failed."},"status":{"type":"string","description":"`pending`, `delivered`, `failed` or `dead`. Open string rather than an enum because the column is `text` with no CHECK constraint."},"response_status":{"type":["integer","null"],"description":"HTTP status the subscriber answered, or null when nothing was ever sent (host check refused, no signing secret, timeout before a response)."},"error":{"type":["string","null"],"description":"Why the attempt did not land, written by the dispatcher as one of `ssrf_blocked:<guard code>` (the runtime DNS and host check refused the URL), `trigger_secret_missing`, `trigger_secret_deref_failed`, `http_<status>`, `timeout`, or the first 256 characters of the thrown message. Null on a delivered attempt."},"delivered_at":{"type":["string","null"],"format":"date-time"},"next_retry_at":{"type":["string","null"],"format":"date-time","description":"When the retry drain may claim this row. Cleared to null the moment a drain claims it, and null on a delivered or dead row."},"created_at":{"type":"string","format":"date-time"}},"required":["id","trigger_id","event_id","attempt","status","response_status","error","delivered_at","next_retry_at","created_at"]}}},"required":["deliveries"]}}}},"404":{"description":"No webhook endpoint with that id in the caller's organization and project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/triggers/{id}/deliveries":{"get":{"deprecated":true,"externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979 - canonical path and alias window"},"summary":"List recent delivery attempts (deprecated path)","description":"DEPRECATED alias of `GET /v1/webhook-endpoints/{id}/deliveries` (ent#979), kept for two releases. Same handler, same required scope, same request and same responses; switch the path and nothing else changes. The canonical path is described in this document too.\n\nAttempts for one endpoint, newest first by `created_at`. Requires `triggers:read`. The endpoint is loaded under the caller's organization and project first, so a delivery cannot be read across tenants; an unknown or foreign endpoint id is 404.\n\n`limit` is the ONLY control: there is no cursor and no total, so an attempt older than the 200 most recent is not reachable through this route. Each row is one attempt, not one event; a retried event appears once per attempt.","parameters":[{"schema":{"type":"string","description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call."},"required":true,"description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call.","name":"id","in":"path"},{"schema":{"type":"integer","default":50,"description":"Rows to return. Clamped into the range 1 to 200: a larger or smaller value is reduced or raised rather than refused, and a value that does not parse as a number falls back to 50."},"required":false,"description":"Rows to return. Clamped into the range 1 to 200: a larger or smaller value is reduced or raised rather than refused, and a value that does not parse as a number falls back to 50.","name":"limit","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"deliveries":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"bigint as string"},"trigger_id":{"type":"string"},"event_id":{"type":"string"},"attempt":{"type":"integer","description":"1 on the first dispatch. History is append only: a retry is a NEW row with `attempt + 1`, never an update of the row that failed."},"status":{"type":"string","description":"`pending`, `delivered`, `failed` or `dead`. Open string rather than an enum because the column is `text` with no CHECK constraint."},"response_status":{"type":["integer","null"],"description":"HTTP status the subscriber answered, or null when nothing was ever sent (host check refused, no signing secret, timeout before a response)."},"error":{"type":["string","null"],"description":"Why the attempt did not land, written by the dispatcher as one of `ssrf_blocked:<guard code>` (the runtime DNS and host check refused the URL), `trigger_secret_missing`, `trigger_secret_deref_failed`, `http_<status>`, `timeout`, or the first 256 characters of the thrown message. Null on a delivered attempt."},"delivered_at":{"type":["string","null"],"format":"date-time"},"next_retry_at":{"type":["string","null"],"format":"date-time","description":"When the retry drain may claim this row. Cleared to null the moment a drain claims it, and null on a delivered or dead row."},"created_at":{"type":"string","format":"date-time"}},"required":["id","trigger_id","event_id","attempt","status","response_status","error","delivered_at","next_retry_at","created_at"]}}},"required":["deliveries"]}}}},"404":{"description":"No webhook endpoint with that id in the caller's organization and project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/webhook-endpoints/{id}/deliveries/{delivery_id}":{"get":{"summary":"Inspect one delivery attempt","description":"One attempt with the request that was sent, the response that came back, and the originating event. Requires `triggers:read`. This is the endpoint for answering `did the subscriber ever see this event, and in what form`.\n\n`request.body` is rebuilt with the dispatcher's own serializer, so it is the byte sequence the signature covered: verify by taking the HMAC of `<request.sent_at_unix>.<request.body>` with the endpoint's signing secret. `request.headers` is re-derived for display rather than stored, the `v1` value in the signature header is redacted, and the legacy signature header the dispatcher also sends is not listed there.\n\nOrder of checks: the endpoint is resolved first, so a foreign or unknown `{id}` is 404 even when `{delivery_id}` is malformed. A malformed `{delivery_id}` under an endpoint the caller owns is 400 `invalid_delivery_id`; a well formed id that names no attempt of THIS endpoint is 404.","parameters":[{"schema":{"type":"string","description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call."},"required":true,"description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call.","name":"id","in":"path"},{"schema":{"type":"string","pattern":"^\\d{1,19}$","description":"Delivery id as decimal digits, the string form of the `bigserial` column. Up to 19 digits, which is the width of a signed 64 bit maximum; anything else is refused with 400 `invalid_delivery_id` before the database is touched."},"required":true,"description":"Delivery id as decimal digits, the string form of the `bigserial` column. Up to 19 digits, which is the width of a signed 64 bit maximum; anything else is refused with 400 `invalid_delivery_id` before the database is touched.","name":"delivery_id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"delivery":{"type":"object","properties":{"id":{"type":"string","description":"bigint as string"},"trigger_id":{"type":"string"},"event_id":{"type":"string"},"attempt":{"type":"integer"},"status":{"type":"string","description":"`pending`, `delivered`, `failed` or `dead`. Open string rather than an enum because the column is `text` with no CHECK constraint."},"response_status":{"type":["integer","null"]},"error":{"type":["string","null"],"description":"Why the attempt did not land, written by the dispatcher as one of `ssrf_blocked:<guard code>` (the runtime DNS and host check refused the URL), `trigger_secret_missing`, `trigger_secret_deref_failed`, `http_<status>`, `timeout`, or the first 256 characters of the thrown message. Null on a delivered attempt."},"delivered_at":{"type":["string","null"],"format":"date-time"},"next_retry_at":{"type":["string","null"],"format":"date-time"},"dead_at":{"type":["string","null"],"format":"date-time"},"receipt_at":{"type":["string","null"],"format":"date-time","description":"Set when the subscriber answered 2xx AND returned `X-CodeSpar-Receipt: ack`. The header is opt in and strengthens the signal only; the contract for delivered is still the 2xx by itself."},"created_at":{"type":"string","format":"date-time"}},"required":["id","trigger_id","event_id","attempt","status","response_status","error","delivered_at","next_retry_at","dead_at","receipt_at","created_at"]},"request":{"type":"object","properties":{"url":{"type":["string","null"],"description":"The URL actually POSTed to, snapshotted at dispatch time, so editing `webhook_url` later does not rewrite delivery history."},"sent_at_unix":{"type":["number","null"],"description":"Unix seconds used as the `t=` value in the signature header. Null when the attempt failed before a signature existed."},"headers":{"type":"object","properties":{"Content-Type":{"type":"string"},"X-CodeSpar-Event":{"type":"string"},"X-CodeSpar-Event-Id":{"type":"string"},"X-CodeSpar-Trigger-Id":{"type":"string"},"X-CodeSpar-Attempt":{"type":"string"},"X-CodeSpar-Signature":{"type":["string","null"],"description":"Rendered as `t=<unix>,v1=<redacted>`. Recomputing the real `v1` would need the signing secret, so it is not shown here."}},"required":["Content-Type","X-CodeSpar-Event","X-CodeSpar-Event-Id","X-CodeSpar-Trigger-Id","X-CodeSpar-Attempt","X-CodeSpar-Signature"],"description":"RE-DERIVED FOR DISPLAY, not stored and not replayed. It is what the dispatcher sends for this attempt, with one omission: the dispatcher also sends `X-CodeSpar-Signature-Legacy`, the bare hex HMAC over the body alone for subscribers that have not migrated to the timestamped scheme, and this block does not list it."},"body":{"type":"string","description":"The exact bytes that were signed and POSTed, as a JSON string: `{ id, type, source, occurred_at, data }`. Rebuilt with the dispatcher's own serializer, so a subscriber can redo the HMAC over `<sent_at_unix>.<body>` and reach the same value it verified at dispatch time."}},"required":["url","sent_at_unix","headers","body"]},"response":{"type":"object","properties":{"status":{"type":["integer","null"]},"body":{"type":["string","null"],"description":"What the subscriber answered, stored as a slice: the dispatcher stops reading after 65536 bytes and stores at most the first 16384 characters, with a trailing ellipsis character appended when it cut."},"error":{"type":["string","null"],"description":"Why the attempt did not land, written by the dispatcher as one of `ssrf_blocked:<guard code>` (the runtime DNS and host check refused the URL), `trigger_secret_missing`, `trigger_secret_deref_failed`, `http_<status>`, `timeout`, or the first 256 characters of the thrown message. Null on a delivered attempt."}},"required":["status","body","error"]},"event":{"type":"object","properties":{"id":{"type":"string"},"source":{"type":"string"},"event_type":{"type":"string"},"payload":{"type":"object","additionalProperties":{}},"received_at":{"type":"string","format":"date-time"},"provider_event_id":{"type":["string","null"]}},"required":["id","source","event_type","payload","received_at","provider_event_id"]}},"required":["delivery","request","response","event"]}}}},"400":{"description":"`delivery_id` is not 1 to 19 decimal digits.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_delivery_id"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"Either no such webhook endpoint in the caller's organization and project, or no delivery with that id belonging to it. Both answer the same code, on purpose.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/triggers/{id}/deliveries/{delivery_id}":{"get":{"deprecated":true,"externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979 - canonical path and alias window"},"summary":"Inspect one delivery attempt (deprecated path)","description":"DEPRECATED alias of `GET /v1/webhook-endpoints/{id}/deliveries/{delivery_id}` (ent#979), kept for two releases. Same handler, same required scope, same request and same responses; switch the path and nothing else changes. The canonical path is described in this document too.\n\nOne attempt with the request that was sent, the response that came back, and the originating event. Requires `triggers:read`. This is the endpoint for answering `did the subscriber ever see this event, and in what form`.\n\n`request.body` is rebuilt with the dispatcher's own serializer, so it is the byte sequence the signature covered: verify by taking the HMAC of `<request.sent_at_unix>.<request.body>` with the endpoint's signing secret. `request.headers` is re-derived for display rather than stored, the `v1` value in the signature header is redacted, and the legacy signature header the dispatcher also sends is not listed there.\n\nOrder of checks: the endpoint is resolved first, so a foreign or unknown `{id}` is 404 even when `{delivery_id}` is malformed. A malformed `{delivery_id}` under an endpoint the caller owns is 400 `invalid_delivery_id`; a well formed id that names no attempt of THIS endpoint is 404.","parameters":[{"schema":{"type":"string","description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call."},"required":true,"description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call.","name":"id","in":"path"},{"schema":{"type":"string","pattern":"^\\d{1,19}$","description":"Delivery id as decimal digits, the string form of the `bigserial` column. Up to 19 digits, which is the width of a signed 64 bit maximum; anything else is refused with 400 `invalid_delivery_id` before the database is touched."},"required":true,"description":"Delivery id as decimal digits, the string form of the `bigserial` column. Up to 19 digits, which is the width of a signed 64 bit maximum; anything else is refused with 400 `invalid_delivery_id` before the database is touched.","name":"delivery_id","in":"path"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"delivery":{"type":"object","properties":{"id":{"type":"string","description":"bigint as string"},"trigger_id":{"type":"string"},"event_id":{"type":"string"},"attempt":{"type":"integer"},"status":{"type":"string","description":"`pending`, `delivered`, `failed` or `dead`. Open string rather than an enum because the column is `text` with no CHECK constraint."},"response_status":{"type":["integer","null"]},"error":{"type":["string","null"],"description":"Why the attempt did not land, written by the dispatcher as one of `ssrf_blocked:<guard code>` (the runtime DNS and host check refused the URL), `trigger_secret_missing`, `trigger_secret_deref_failed`, `http_<status>`, `timeout`, or the first 256 characters of the thrown message. Null on a delivered attempt."},"delivered_at":{"type":["string","null"],"format":"date-time"},"next_retry_at":{"type":["string","null"],"format":"date-time"},"dead_at":{"type":["string","null"],"format":"date-time"},"receipt_at":{"type":["string","null"],"format":"date-time","description":"Set when the subscriber answered 2xx AND returned `X-CodeSpar-Receipt: ack`. The header is opt in and strengthens the signal only; the contract for delivered is still the 2xx by itself."},"created_at":{"type":"string","format":"date-time"}},"required":["id","trigger_id","event_id","attempt","status","response_status","error","delivered_at","next_retry_at","dead_at","receipt_at","created_at"]},"request":{"type":"object","properties":{"url":{"type":["string","null"],"description":"The URL actually POSTed to, snapshotted at dispatch time, so editing `webhook_url` later does not rewrite delivery history."},"sent_at_unix":{"type":["number","null"],"description":"Unix seconds used as the `t=` value in the signature header. Null when the attempt failed before a signature existed."},"headers":{"type":"object","properties":{"Content-Type":{"type":"string"},"X-CodeSpar-Event":{"type":"string"},"X-CodeSpar-Event-Id":{"type":"string"},"X-CodeSpar-Trigger-Id":{"type":"string"},"X-CodeSpar-Attempt":{"type":"string"},"X-CodeSpar-Signature":{"type":["string","null"],"description":"Rendered as `t=<unix>,v1=<redacted>`. Recomputing the real `v1` would need the signing secret, so it is not shown here."}},"required":["Content-Type","X-CodeSpar-Event","X-CodeSpar-Event-Id","X-CodeSpar-Trigger-Id","X-CodeSpar-Attempt","X-CodeSpar-Signature"],"description":"RE-DERIVED FOR DISPLAY, not stored and not replayed. It is what the dispatcher sends for this attempt, with one omission: the dispatcher also sends `X-CodeSpar-Signature-Legacy`, the bare hex HMAC over the body alone for subscribers that have not migrated to the timestamped scheme, and this block does not list it."},"body":{"type":"string","description":"The exact bytes that were signed and POSTed, as a JSON string: `{ id, type, source, occurred_at, data }`. Rebuilt with the dispatcher's own serializer, so a subscriber can redo the HMAC over `<sent_at_unix>.<body>` and reach the same value it verified at dispatch time."}},"required":["url","sent_at_unix","headers","body"]},"response":{"type":"object","properties":{"status":{"type":["integer","null"]},"body":{"type":["string","null"],"description":"What the subscriber answered, stored as a slice: the dispatcher stops reading after 65536 bytes and stores at most the first 16384 characters, with a trailing ellipsis character appended when it cut."},"error":{"type":["string","null"],"description":"Why the attempt did not land, written by the dispatcher as one of `ssrf_blocked:<guard code>` (the runtime DNS and host check refused the URL), `trigger_secret_missing`, `trigger_secret_deref_failed`, `http_<status>`, `timeout`, or the first 256 characters of the thrown message. Null on a delivered attempt."}},"required":["status","body","error"]},"event":{"type":"object","properties":{"id":{"type":"string"},"source":{"type":"string"},"event_type":{"type":"string"},"payload":{"type":"object","additionalProperties":{}},"received_at":{"type":"string","format":"date-time"},"provider_event_id":{"type":["string","null"]}},"required":["id","source","event_type","payload","received_at","provider_event_id"]}},"required":["delivery","request","response","event"]}}}},"400":{"description":"`delivery_id` is not 1 to 19 decimal digits.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_delivery_id"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"Either no such webhook endpoint in the caller's organization and project, or no delivery with that id belonging to it. Both answer the same code, on purpose.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/webhook-endpoints/{id}/dlq":{"get":{"summary":"List dead lettered deliveries","description":"The attempts that exhausted the retry ladder, `status = 'dead'`, newest first by `dead_at` with nulls last. Requires `triggers:read`. Separate from the deliveries listing so an operator can page the failures without wading through successful attempts.\n\nA FIRST delivery reaches here after five attempts: the initial one plus four retries, whose earliest times are 1 minute, 5 minutes, 30 minutes and 2 hours after the attempt they follow, so about two and a half hours at best. A retry only fires when a drain claims the row, so the real elapsed time can be longer. A REDELIVERY gets fewer, and the redeliver operation explains why. Nothing retries a dead row on its own: it is replayed only by the redeliver call.\n\nSame single `limit` control as the deliveries listing, with no cursor.","parameters":[{"schema":{"type":"string","description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call."},"required":true,"description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call.","name":"id","in":"path"},{"schema":{"type":"integer","default":50,"description":"Rows to return. Clamped into the range 1 to 200: a larger or smaller value is reduced or raised rather than refused, and a value that does not parse as a number falls back to 50."},"required":false,"description":"Rows to return. Clamped into the range 1 to 200: a larger or smaller value is reduced or raised rather than refused, and a value that does not parse as a number falls back to 50.","name":"limit","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"dead_letters":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"bigint as string"},"event_id":{"type":"string"},"attempt":{"type":"integer"},"response_status":{"type":["integer","null"]},"error":{"type":["string","null"],"description":"Why the attempt did not land, written by the dispatcher as one of `ssrf_blocked:<guard code>` (the runtime DNS and host check refused the URL), `trigger_secret_missing`, `trigger_secret_deref_failed`, `http_<status>`, `timeout`, or the first 256 characters of the thrown message. Null on a delivered attempt."},"dead_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"}},"required":["id","event_id","attempt","response_status","error","dead_at","created_at"]}}},"required":["dead_letters"]}}}},"404":{"description":"No webhook endpoint with that id in the caller's organization and project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/triggers/{id}/dlq":{"get":{"deprecated":true,"externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979 - canonical path and alias window"},"summary":"List dead lettered deliveries (deprecated path)","description":"DEPRECATED alias of `GET /v1/webhook-endpoints/{id}/dlq` (ent#979), kept for two releases. Same handler, same required scope, same request and same responses; switch the path and nothing else changes. The canonical path is described in this document too.\n\nThe attempts that exhausted the retry ladder, `status = 'dead'`, newest first by `dead_at` with nulls last. Requires `triggers:read`. Separate from the deliveries listing so an operator can page the failures without wading through successful attempts.\n\nA FIRST delivery reaches here after five attempts: the initial one plus four retries, whose earliest times are 1 minute, 5 minutes, 30 minutes and 2 hours after the attempt they follow, so about two and a half hours at best. A retry only fires when a drain claims the row, so the real elapsed time can be longer. A REDELIVERY gets fewer, and the redeliver operation explains why. Nothing retries a dead row on its own: it is replayed only by the redeliver call.\n\nSame single `limit` control as the deliveries listing, with no cursor.","parameters":[{"schema":{"type":"string","description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call."},"required":true,"description":"Endpoint id, `trg_` followed by a 16 character nanoid, as minted by the create call.","name":"id","in":"path"},{"schema":{"type":"integer","default":50,"description":"Rows to return. Clamped into the range 1 to 200: a larger or smaller value is reduced or raised rather than refused, and a value that does not parse as a number falls back to 50."},"required":false,"description":"Rows to return. Clamped into the range 1 to 200: a larger or smaller value is reduced or raised rather than refused, and a value that does not parse as a number falls back to 50.","name":"limit","in":"query"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"dead_letters":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"bigint as string"},"event_id":{"type":"string"},"attempt":{"type":"integer"},"response_status":{"type":["integer","null"]},"error":{"type":["string","null"],"description":"Why the attempt did not land, written by the dispatcher as one of `ssrf_blocked:<guard code>` (the runtime DNS and host check refused the URL), `trigger_secret_missing`, `trigger_secret_deref_failed`, `http_<status>`, `timeout`, or the first 256 characters of the thrown message. Null on a delivered attempt."},"dead_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"}},"required":["id","event_id","attempt","response_status","error","dead_at","created_at"]}}},"required":["dead_letters"]}}}},"404":{"description":"No webhook endpoint with that id in the caller's organization and project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/webhook-endpoints/retry-pending":{"post":{"summary":"Drain the retry queue for this project","description":"Dispatches the deliveries that are due for another attempt, right now, instead of waiting for the in-process worker tick. Requires `triggers:write`. The handler reads no request body and takes no parameters.\n\nA row is claimed only when ALL of it holds: its status is `failed` or `pending`, its `next_retry_at` has passed, and its endpoint is `active`. An endpoint that auto-paused into `error` therefore drains nothing until a patch reactivates it. Scope is the caller's organization AND project; one project cannot drain a sibling project's queue.\n\nOne call claims at most 100 rows, and the limit is not settable from here, so a backlog larger than that needs repeated calls. The claim uses FOR UPDATE SKIP LOCKED, so calling this while the worker is running is safe: the two get disjoint rows rather than dispatching the same event twice.\n\nThe counters describe what happened during the call. `scanned` is what was claimed; a claimed row is dispatched synchronously before this returns, so a large drain is a slow request.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"scanned":{"type":"integer","description":"Rows the claim actually took, which is also the ceiling on the four counters below."},"redispatched":{"type":"integer"},"delivered":{"type":"integer"},"failed":{"type":"integer","description":"Attempts that failed and are scheduled for another retry."},"dead":{"type":"integer","description":"Attempts that failed on the last allowed attempt and were dead lettered."}},"required":["scanned","redispatched","delivered","failed","dead"]}}}}}}},"/v1/triggers/retry-pending":{"post":{"deprecated":true,"externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979 - canonical path and alias window"},"summary":"Drain the retry queue for this project (deprecated path)","description":"DEPRECATED alias of `POST /v1/webhook-endpoints/retry-pending` (ent#979), kept for two releases. Same handler, same required scope, same request and same responses; switch the path and nothing else changes. The canonical path is described in this document too.\n\nDispatches the deliveries that are due for another attempt, right now, instead of waiting for the in-process worker tick. Requires `triggers:write`. The handler reads no request body and takes no parameters.\n\nA row is claimed only when ALL of it holds: its status is `failed` or `pending`, its `next_retry_at` has passed, and its endpoint is `active`. An endpoint that auto-paused into `error` therefore drains nothing until a patch reactivates it. Scope is the caller's organization AND project; one project cannot drain a sibling project's queue.\n\nOne call claims at most 100 rows, and the limit is not settable from here, so a backlog larger than that needs repeated calls. The claim uses FOR UPDATE SKIP LOCKED, so calling this while the worker is running is safe: the two get disjoint rows rather than dispatching the same event twice.\n\nThe counters describe what happened during the call. `scanned` is what was claimed; a claimed row is dispatched synchronously before this returns, so a large drain is a slow request.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"scanned":{"type":"integer","description":"Rows the claim actually took, which is also the ceiling on the four counters below."},"redispatched":{"type":"integer"},"delivered":{"type":"integer"},"failed":{"type":"integer","description":"Attempts that failed and are scheduled for another retry."},"dead":{"type":"integer","description":"Attempts that failed on the last allowed attempt and were dead lettered."}},"required":["scanned","redispatched","delivered","failed","dead"]}}}}}}},"/v1/webhook-endpoints/deliveries/{delivery_id}/redeliver":{"post":{"summary":"Queue a delivery to be sent again","description":"Queues a fresh attempt at the SAME event, typically for a dead lettered one. Requires `triggers:write`. The handler reads no request body. Note the path: the delivery id is enough, no endpoint id, because the delivery is resolved by joining its endpoint and filtering on the caller's organization and project.\n\nNOTHING IS SENT BY THIS CALL. It inserts a new delivery row with `attempt = 1`, status `pending` and `next_retry_at = now()`, then answers 202. The POST happens when a drain picks the row up, either the in-process worker or the retry-pending call. Since a drain only claims rows whose endpoint is `active`, and this call deliberately does NOT reactivate a paused or auto-paused endpoint, read `trigger_status` in the response: anything other than `active` means the row will sit unclaimed until a patch reactivates the endpoint.\n\nCOUNT A REDELIVERY'S ATTEMPTS FROM 2. The row inserted here is a marker, not an attempt: the drain reads its `attempt` and dispatches at `attempt + 1`, so the first POST a redelivery actually makes carries attempt 2, and the retry ladder is entered at that rung rather than at the top. A redelivery therefore gets FOUR real POSTs, attempts 2 through 5, spaced by the ladder's 5 minute, 30 minute and 2 hour rungs, and the fifth is terminal — it dead letters instead of scheduling a sixth. Redelivering does not hand the event a fresh set of five.\n\nIt is NOT idempotent. Two calls queue two attempts at the same event and the subscriber receives it twice, with the same `X-CodeSpar-Event-Id`.\n\nTwo side effects on top of the insert: the origin row's `next_retry_at` is cleared so it cannot be claimed as well, and the endpoint's consecutive failure streak is reset to 0, which restarts the auto-pause clock.","parameters":[{"schema":{"type":"string","pattern":"^\\d{1,19}$","description":"Delivery id as decimal digits, the string form of the `bigserial` column. Up to 19 digits, which is the width of a signed 64 bit maximum; anything else is refused with 400 `invalid_delivery_id` before the database is touched."},"required":true,"description":"Delivery id as decimal digits, the string form of the `bigserial` column. Up to 19 digits, which is the width of a signed 64 bit maximum; anything else is refused with 400 `invalid_delivery_id` before the database is touched.","name":"delivery_id","in":"path"}],"responses":{"202":{"description":"Accepted","content":{"application/json":{"schema":{"type":"object","properties":{"redelivery_id":{"type":"string","description":"Id of the NEW pending delivery row, bigint as string."},"trigger_id":{"type":"string"},"event_id":{"type":"string"},"trigger_status":{"type":"string","description":"Status of the endpoint the delivery belongs to, read before the insert. Check it: the drain only claims rows whose endpoint is `active`, so a redelivery queued against a `paused` or `error` endpoint sits there until a patch reactivates it."}},"required":["redelivery_id","trigger_id","event_id","trigger_status"]}}}},"400":{"description":"`delivery_id` is not 1 to 19 decimal digits.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_delivery_id"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"No delivery with that id whose webhook endpoint belongs to the caller's organization and project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}},"/v1/triggers/deliveries/{delivery_id}/redeliver":{"post":{"deprecated":true,"externalDocs":{"url":"https://github.com/codespar/codespar-enterprise/issues/979","description":"ent#979 - canonical path and alias window"},"summary":"Queue a delivery to be sent again (deprecated path)","description":"DEPRECATED alias of `POST /v1/webhook-endpoints/deliveries/{delivery_id}/redeliver` (ent#979), kept for two releases. Same handler, same required scope, same request and same responses; switch the path and nothing else changes. The canonical path is described in this document too.\n\nQueues a fresh attempt at the SAME event, typically for a dead lettered one. Requires `triggers:write`. The handler reads no request body. Note the path: the delivery id is enough, no endpoint id, because the delivery is resolved by joining its endpoint and filtering on the caller's organization and project.\n\nNOTHING IS SENT BY THIS CALL. It inserts a new delivery row with `attempt = 1`, status `pending` and `next_retry_at = now()`, then answers 202. The POST happens when a drain picks the row up, either the in-process worker or the retry-pending call. Since a drain only claims rows whose endpoint is `active`, and this call deliberately does NOT reactivate a paused or auto-paused endpoint, read `trigger_status` in the response: anything other than `active` means the row will sit unclaimed until a patch reactivates the endpoint.\n\nCOUNT A REDELIVERY'S ATTEMPTS FROM 2. The row inserted here is a marker, not an attempt: the drain reads its `attempt` and dispatches at `attempt + 1`, so the first POST a redelivery actually makes carries attempt 2, and the retry ladder is entered at that rung rather than at the top. A redelivery therefore gets FOUR real POSTs, attempts 2 through 5, spaced by the ladder's 5 minute, 30 minute and 2 hour rungs, and the fifth is terminal — it dead letters instead of scheduling a sixth. Redelivering does not hand the event a fresh set of five.\n\nIt is NOT idempotent. Two calls queue two attempts at the same event and the subscriber receives it twice, with the same `X-CodeSpar-Event-Id`.\n\nTwo side effects on top of the insert: the origin row's `next_retry_at` is cleared so it cannot be claimed as well, and the endpoint's consecutive failure streak is reset to 0, which restarts the auto-pause clock.","parameters":[{"schema":{"type":"string","pattern":"^\\d{1,19}$","description":"Delivery id as decimal digits, the string form of the `bigserial` column. Up to 19 digits, which is the width of a signed 64 bit maximum; anything else is refused with 400 `invalid_delivery_id` before the database is touched."},"required":true,"description":"Delivery id as decimal digits, the string form of the `bigserial` column. Up to 19 digits, which is the width of a signed 64 bit maximum; anything else is refused with 400 `invalid_delivery_id` before the database is touched.","name":"delivery_id","in":"path"}],"responses":{"202":{"description":"Accepted","content":{"application/json":{"schema":{"type":"object","properties":{"redelivery_id":{"type":"string","description":"Id of the NEW pending delivery row, bigint as string."},"trigger_id":{"type":"string"},"event_id":{"type":"string"},"trigger_status":{"type":"string","description":"Status of the endpoint the delivery belongs to, read before the insert. Check it: the drain only claims rows whose endpoint is `active`, so a redelivery queued against a `paused` or `error` endpoint sits there until a patch reactivates it."}},"required":["redelivery_id","trigger_id","event_id","trigger_status"]}}}},"400":{"description":"`delivery_id` is not 1 to 19 decimal digits.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_delivery_id"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}},"404":{"description":"No delivery with that id whose webhook endpoint belongs to the caller's organization and project.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["not_found"]},"message":{"type":"string"},"details":{"type":"object","additionalProperties":{}}},"required":["code","message"]},"request_id":{"type":["string","null"],"description":"Echoes the `X-Request-Id` header when the request carried one."}},"required":["error","request_id"]}}}}}}}},"webhooks":{}}