{"data":{"auth":{"boundaries":"Credential families never cross: an API key (ak_* or ww_*) never reaches a route under /v1/control; cpt_* and session cookies are control-plane only; the console's session + X-Project-Id binding is the third route.","families":[{"authorizes":"control resources only","name":"control_personal_token","transport":"Authorization: Bearer cpt_*","validation":"hash-only via ControlPlaneRepository::authenticate"},{"authorizes":"the Tools part of an API key: the tools surface, /mcp and the File routes of one project","model":"One kind of API key (issue #2257): the organization owns it, and it has a Tools part (one project), a channel part (a Warta key scope: the organization, one channel or one business address), or both. A route accepts a key that has the part it needs; a valid key without that part is 403 Auth_KeyOutOfReach. GET /v1/key answers the calling key's id, name, masked value, organization and parts. Many named keys; ANY active key authenticates. Keys are stored as SHA-256 hashes and the raw value is returned exactly once, by the mint response (POST /v1/control/organizations/{organization_id}/keys, or the older POST /v1/control/projects/{project_id}/keys). A key that a Warta key route minted starts with ww_ and is the same kind. Rotation is mint new -> migrate consumers -> revoke old; the overlap is intended and there is no atomic rotate endpoint.","name":"project_api_key","transport":"Authorization: Bearer ak_*; the x-api-key header (ak_*) is also accepted"}],"note":"org_api_key (x-org-api-key, oak_*) is RETIRED (decision #113): no organization credential exists, and the header is rejected as an invalid credential.","rule":"exactly one auth family per request; two families in one request is rejected (400 Auth_MultipleCredentials)"},"base_path":"/v1/control","envelope":{"error":{"shape":{"error":{"code":"integer","message":"string","request_id":"string","slug":"string","status":"integer","suggested_fix":"string"},"errors":["string (400 validation only)"]}},"request_id":{"auth_use":"never used for authentication","echoed":"x-request-id response header on every v1 response","inbound":"X-Request-ID header (validated: alnum + ._- , len <= 128) or generated"},"success":{"shape":{"data":"any","meta":{"request_id":"string","total":"integer (lists only)"}}}},"path_rule":"absolute_path = base_path + path. Every route entry carries absolute_path precomputed; send requests to absolute_path, never to path alone and never to /v1 + path.","routes":[{"absolute_path":"/v1/control/bootstrap/exchange","auth":"browser session only, with a same-origin proof; no authority check — the one-time bootstrap token of the deployment, in the body (bootstrap_token or token) or in the x-bootstrap-token header, authorizes","method":"POST","path":"/bootstrap/exchange","summary":"Promote the signed-in caller to platform operator, one time, with the bootstrap token that the operator of this deployment holds. It mints no token, no principal and no installation. A customer does not need it: use a Key request or machine sign-in (see /docs/api)"},{"absolute_path":"/v1/control/session","auth":"control_personal_token; no authority check — a caller reads its OWN session and nothing else","method":"GET","path":"/session","summary":"Current principal session: {principal, installation, role, email, token_id, token_expires_at, token_last_used_at, sign_in_method}. email is the caller's OWN address as the identity provider last asserted it on sign-in, or null when it asserted none (issue #1353). It is on this answer only: the principal object inside it, and every answer about another principal, carries no address. sign_in_method is how a browser Session signed in (password, google.com, ...), or null for a control personal token (issue #1581)"},{"absolute_path":"/v1/control/installations","auth":"control_personal_token with an installation.list grant","method":"GET","path":"/installations","summary":"List installations"},{"absolute_path":"/v1/control/installations","auth":"control_personal_token with an installation.create grant","method":"POST","path":"/installations","summary":"Create an installation"},{"absolute_path":"/v1/control/installations/{installation_id}","auth":"control_personal_token with an installation.read grant","method":"GET","path":"/installations/{installation_id}","summary":"Get an installation"},{"absolute_path":"/v1/control/organizations","auth":"control_personal_token with an organization.list grant","method":"GET","path":"/organizations","summary":"List organizations. Each carries deletion_scheduled_for: the delete_after of its scheduled deletion, RFC 3339, or null (issue #1672); and name_change_scheduled_for: the date on which a principal's deletion renames it to \"Former member's organization\", RFC 3339, or null (issue #1853); and channel_stops: [{channel, stopped_at}], the open Channel stops of the organization, an empty array when no channel is stopped (issue #1944). Every member reads it, and it never holds the reason or the platform reference. It also lists each organization the caller reaches only through an active project membership, and each row carries access: \"organization\" (an organization-scope membership or a platform operator) or \"project\" (issue #1965); and role: the caller's role in the organization, \"platform_operator\" for a platform operator, else the built-in role of its organization-scope membership there or null for a custom role, else the higher built-in role of its project memberships there, \"project_admin\" above \"project_member\" (issue #2261)"},{"absolute_path":"/v1/control/organizations","auth":"control_personal_token with an organization.create grant","method":"POST","path":"/organizations","summary":"Create an organization"},{"absolute_path":"/v1/control/organizations/{organization_id}","auth":"control_personal_token with an organization.read grant","method":"GET","path":"/organizations/{organization_id}","summary":"Get an organization, with deletion_scheduled_for: the delete_after of its scheduled deletion, RFC 3339, or null (issue #1672); and name_change_scheduled_for: the date on which a principal's deletion renames it to \"Former member's organization\", RFC 3339, or null (issue #1853); and channel_stops: [{channel, stopped_at}], the open Channel stops of the organization, an empty array when no channel is stopped (issue #1944). Every member reads it, and it never holds the reason or the platform reference"},{"absolute_path":"/v1/control/organizations/{organization_id}","auth":"control_personal_token with org_owner authority or above at a binding that reaches {organization_id}. No grant substitutes for it, so a custom role never admits a caller","method":"PATCH","path":"/organizations/{organization_id}","summary":"Rename the organization (issue #1506): {name}, not blank and at most 120 characters. Answers the renamed organization. Organization names are not unique in the installation (issue #1563), so two organizations can have the same name: a name another organization holds is saved, and the rename reads no other organization's name. The rename appends a control.organization.rename security audit event with the old and the new name in its own transaction, and a renamed organization is never reclaimed by an invitation acceptance as a first-login organization"},{"absolute_path":"/v1/control/organizations/{organization_id}/deletion","auth":"browser session only (a cpt_* control_personal_token is 401 Auth_MissingSession), with org_owner authority or above at a binding that reaches {organization_id}. No grant substitutes for it; an organization outside the caller's reach is 404 as for GET /organizations/{organization_id}","method":"GET","path":"/organizations/{organization_id}/deletion","summary":"Preview the deletion of the organization (issue #1672): {scheduled: null or {delete_after, requested_by_name}, delete_after of a request made now, counts: {projects, whatsapp_business_accounts}, members: [{principal_id, display_name, role}], notices_by_email: true when a schedule emails its notice to each member (issue #1673)}. Nothing is written"},{"absolute_path":"/v1/control/organizations/{organization_id}/deletion","auth":"browser session only (a cpt_* control_personal_token is 401 Auth_MissingSession), with org_owner authority or above at a binding that reaches {organization_id} (no grant substitutes for it), then a fresh sign-in: {id_token} must verify and its auth_time be at most 5 minutes old (401 Principal_ReauthenticationRequired), with the issuer and subject of the session principal (403 Principal_IdentityMismatch)","method":"POST","path":"/organizations/{organization_id}/deletion","summary":"Schedule the deletion of the organization 30 days from now (issue #1672). In one transaction: the schedule row, every pending invitation revoked, and a control.organization.deletion_scheduled security audit event. Answers {organization_id, requested_at, delete_after}; a second request answers the existing schedule and changes nothing. Until the deletion the organization is paused: its Project keys answer 401 Auth_InvalidCredential, its Warta keys 401 Messaging_KeyInvalid, and POST /organizations/{organization_id}/invitations is 409 Organization_DeletionScheduled. The worker deletes the organization on its first pass after delete_after. When the deployment sends the organization-deletion mails (issue #1673), the same transaction queues a notice to each principal with an active membership in the organization or one of its projects and an email address, and the worker queues a reminder 3 days before delete_after"},{"absolute_path":"/v1/control/organizations/{organization_id}/deletion","auth":"browser session only (a cpt_* control_personal_token is 401 Auth_MissingSession), with org_owner authority or above at a binding that reaches {organization_id} (no grant substitutes for it). No fresh sign-in","method":"DELETE","path":"/organizations/{organization_id}/deletion","summary":"Cancel the scheduled deletion (issue #1672): the schedule row is deleted and a control.organization.deletion_canceled security audit event appended in one transaction. Answers the schedule that ended; 404 Resource_NotFound when none is scheduled. The keys answer again; the revoked invitations stay revoked. When the deployment sends the cancel mail (issue #1673), the same transaction queues it to each member with an email address, and no reminder follows"},{"absolute_path":"/v1/control/organizations/{organization_id}/channel-stops","auth":"control_personal_token or session cookie with a membership.list grant reaching {organization_id} (platform_operator only). An organization in the caller's reach whose caller is not a platform operator is 403 Control_Forbidden; an organization outside the caller's reach is 404, as for GET /organizations/{organization_id}","method":"GET","path":"/organizations/{organization_id}/channel-stops","summary":"List the Channel stops of the organization, open and lifted, newest first (issue #1944, decided in #1866). Each is {channel, reason, platform_reference, stopped_at, stopped_by, stopped_by_name, lifted_at, lifted_by, lifted_by_name, accounts: [{waba_id, meta_waba_id, unsubscribe_outcome, resubscribe_outcome}]}. The reason and the platform reference are on these three routes only"},{"absolute_path":"/v1/control/organizations/{organization_id}/channel-stops","auth":"control_personal_token or session cookie with a membership.list grant reaching {organization_id} (platform_operator only): 403 Control_Forbidden for any other caller that reaches the organization, 404 outside its reach","method":"POST","path":"/organizations/{organization_id}/channel-stops","summary":"Start a Channel stop: stop the organization's use of one channel because the channel's platform asked for it, and keep its data (issue #1944). Body {channel: \"whatsapp\", reason (1-2000 characters), platform_reference? (at most 200 characters)}. 201 with the stop as the list serves it. 409 Control_Conflict when the channel is already stopped; 400 Control_InvalidInput for a blank reason, a field over its bound or an unknown channel. In one transaction: the stop, the cancel of each scheduled, running or paused proactive run of the organization, and a control.organization.channel_stop security audit event with {organization_id, channel, reason, platform_reference, cancelled_run_ids}. After the commit each Embedded Signup WhatsApp Business Account that no other mirror shares gets one DELETE /{meta_waba_id}/subscribed_apps at Meta, recorded as accounts[].unsubscribe_outcome and as one messaging.waba.meta_unsubscribe event; Manual custody gets no call (not_applicable). No business phone number is deregistered. During the stop every /v1/messaging route of the organization answers 409 Messaging_ChannelStopped except the account list and detail, DELETE /wabas/{waba_id}, the Warta key routes, the Subscription routes, the Team routes and the two /embedded configuration reads; every /v1/control/proactive route answers 409 Proactive_ChannelStopped; GET /exports/contacts and GET /exports/organization leave the channel's data out and keep every other channel's (#2199); inbound messages and statuses are acknowledged and not stored; and a WhatsApp Delivery ends as failed. A connect of a Meta WABA id that the organization held at the stop is refused in every other organization of the installation with 409 Messaging_ChannelStopped"},{"absolute_path":"/v1/control/organizations/{organization_id}/channel-stops/{channel}","auth":"control_personal_token or session cookie with a membership.list grant reaching {organization_id} (platform_operator only): 403 Control_Forbidden for any other caller that reaches the organization, 404 outside its reach","method":"DELETE","path":"/organizations/{organization_id}/channel-stops/{channel}","summary":"End the open Channel stop of {channel} (issue #1944). 200 with the stop and its lifted_at and lifted_by; 404 Resource_NotFound when the organization has no open stop of the channel; 400 Control_InvalidInput for an unknown channel. The stamp and a control.organization.channel_stop_lift security audit event are one transaction. After the commit each Embedded Signup account whose unsubscribe_outcome was unsubscribed gets one POST /{meta_waba_id}/subscribed_apps at Meta, recorded as accounts[].resubscribe_outcome (subscribed, token_invalid, access_lost or failed) and as one messaging.waba.meta_resubscribe event; Graph code 190 sets the account EXPIRED and codes 200 and 10 set it ACCESS_LOST, which Reconnect repairs. Every refused route answers again. A cancelled run stays cancelled, an inbound event that arrived during the stop is not recovered, and a failed Delivery can be replayed"},{"absolute_path":"/v1/control/organizations/{organization_id}/auth_configs","auth":"control_personal_token or browser session with org_admin authority or above at a binding that reaches {organization_id}. No grant substitutes for it, so a custom role never admits a caller. An organization outside the caller's reach is 404 Resource_NotFound; a member below the bar is 403 Control_Forbidden","method":"GET","path":"/organizations/{organization_id}/auth_configs","summary":"List the organization's auth configs, oldest first (issue #1985): each {id, toolkit_slug, is_composio_managed, client_id, created_at, connected_accounts, projects}, where connected_accounts counts the connected accounts that use it in every project and projects counts the projects that hold one. No secret"},{"absolute_path":"/v1/control/organizations/{organization_id}/auth_configs/{auth_config_id}","auth":"control_personal_token or browser session with org_admin authority or above at a binding that reaches {organization_id}. No grant substitutes for it, so a custom role never admits a caller. An organization outside the caller's reach is 404 Resource_NotFound; a member below the bar is 403 Control_Forbidden","method":"DELETE","path":"/organizations/{organization_id}/auth_configs/{auth_config_id}","summary":"Delete one auth config of the organization with every connected account that uses it, in every project (issue #1985). No body. The provider revoke rule always runs first, for those accounts; its outcome never stops the delete and is not in the answer. Then one transaction deletes their link tokens and OAuth states, the accounts and the auth config. Answers {id, deleted: true, connected_accounts}; an unknown id or an id of another organization is 404 Resource_NotFound"},{"absolute_path":"/v1/control/organizations/{organization_id}/invitations","auth":"control_personal_token with a membership.grant grant reaching {organization_id}, PLUS the delegation guard POST /memberships applies — the invited role's grants must be ones the inviter already holds. Beside the guard, the owner rule (issue #1691): an Organization owner of {organization_id} offers org_owner, and then needs a fresh sign-in: a browser session sends the header x-fresh-id-token with an ID token of the session principal whose auth_time is at most 5 minutes old (no header or a stale token is 401 Principal_ReauthenticationRequired, another identity 403 Principal_IdentityMismatch, a cpt_* control_personal_token 403 Control_Forbidden)","method":"POST","path":"/organizations/{organization_id}/invitations","summary":"Issue an invitation to join the organization ({email, optional role, optional project_id}, default org_admin). With project_id it is a Project Invitation (issue #1965): role is project_admin or project_member (default project_member), the project must be a live project of the organization (404 Resource_NotFound otherwise), the grant gate and the delegation guard run at the project's scope, so a project_admin offers project_member on its own project, and no fresh sign-in is asked for; acceptance grants one project membership and reclaims no organization. This response is the ONLY place the raw invitation token ever appears. On a deployment with a mail credential the same transaction queues the invitation email and the invitation reports email_delivery: queued (issue #964); on one without, email_delivery is null and the inviter delivers the link themselves"},{"absolute_path":"/v1/control/organizations/{organization_id}/invitations","auth":"control_personal_token with a membership.list grant reaching {organization_id}","method":"GET","path":"/organizations/{organization_id}/invitations","summary":"List the organization's invitations, each with its resolved status — pending, accepted, revoked, or expired, where expired is derived at read time and never persisted — and project_id, the project of a Project Invitation or null (issue #1965). ?project_id= lists the invitations of that project only, gated at the project's scope, so a project_admin lists the invitations of its project"},{"absolute_path":"/v1/control/organizations/{organization_id}/invitations/{invitation_id}","auth":"control_personal_token with a membership.revoke grant reaching {organization_id}, PLUS the delegation guard on the invitation's own role — the caller's grants must strictly cover that role's, so only a caller who could issue this invitation may withdraw it (decision #1131); beside the guard, the owner rule admits an Organization owner of {organization_id} to an org_owner invitation, with no fresh sign-in (issue #1691). It runs right after the invitation is found and before any state check, so a refused caller learns nothing about the invitation's state","method":"DELETE","path":"/organizations/{organization_id}/invitations/{invitation_id}","summary":"Withdraw a pending invitation. An ACCEPTED one cannot be revoked here (409): revoke the membership it created, through the membership surface"},{"absolute_path":"/v1/control/organizations/{organization_id}/invitations/{invitation_id}/resend","auth":"control_personal_token with a membership.grant grant reaching {organization_id}, PLUS the delegation guard issuing applies, on the invitation's own role — the caller's grants must strictly cover that role's, so only a caller who could issue this invitation may send it again (decision #1131); beside the guard, the owner rule admits an Organization owner of {organization_id} to an org_owner invitation, with no fresh sign-in (issue #1691). It runs right after the invitation is found and before every state and email check, so a refused caller learns nothing about the invitation or its email","method":"POST","path":"/organizations/{organization_id}/invitations/{invitation_id}/resend","summary":"Re-queue a FAILED invitation email (issue #964). No body. A REPLAY of the failed delivery rather than a new invitation: the raw token cannot be re-derived, so the invitee receives exactly the link written the first time and expires_at is untouched. Refused for every other delivery state (409 Invitation_EmailNotFailed), for an invitation that is no longer pending (409 Invitation_NotPending), and when no mail was ever queued (404 Invitation_EmailNotQueued)"},{"absolute_path":"/v1/control/invitations/preview","auth":"control_personal_token; NO authority check — the invitation token in the body is the authorization, resolved inside the caller's own installation","method":"POST","path":"/invitations/preview","summary":"What the invitee is shown before deciding: {token}. Answers the organization, the offered role, the refusal that acceptance would give if there is one, and project: {id, name} of a Project Invitation or null (issue #1965). Authenticated rather than public, because the identity being evaluated is the caller's own and they need one to accept anyway"},{"absolute_path":"/v1/control/invitations/lookup","auth":"none (the invitation token in the body) — public, mounted outside the control credential family, so a signed-out person can call it; resolved in the deployment's one installation","method":"POST","path":"/invitations/lookup","summary":"What a signed-out holder of an invitation token is shown (issue #1590): {token}. Answers {organization_name, status} and nothing else — no address, no role, no refusal — plus project_name for a Project Invitation (issue #1965), with status resolved at read time (pending, accepted, revoked or expired). 404 for a token that matches nothing. Writes no audit row"},{"absolute_path":"/v1/control/invitations/accept","auth":"control_personal_token; NO authority check — the token in the body authorizes, and acceptance additionally requires the caller's VERIFIED email to equal the invited address","method":"POST","path":"/invitations/accept","summary":"Redeem an invitation as the signed-in identity: {token}. Answers the invitation, the organization, the membership, whether the caller was already an active member (the invitation is consumed either way), any first-login organization reclaimed in the same transaction, and project: {id, name} of a Project Invitation or null (issue #1965). A Project Invitation grants one project membership, reclaims no organization, and keeps an active organization-scope or project membership as already_member"},{"absolute_path":"/v1/control/key_requests","auth":"none (public: an agent with no credential starts it; 10 an hour for each client address)","method":"POST","path":"/key_requests","summary":"Start a key request: an agent with no credential asks a person for a project key. Answers device_code, user_code, verification_uri, verification_uri_complete, expires_in (600) and interval (5). An optional toolkits list (at most 8 offered toolkit slugs) names the toolkits that the page offers to connect after the allow"},{"absolute_path":"/v1/control/key_requests/token","auth":"none (public: the device_code in the body is the only proof)","method":"POST","path":"/key_requests/token","summary":"Poll a key request: 202 while the person has not answered, 200 with a new project key one time after an allow, 403 KeyRequest_Denied after a deny, 429 KeyRequest_SlowDown when polled sooner than interval, 400 KeyRequest_Expired for an expired, collected or unknown request"},{"absolute_path":"/v1/control/key_requests/lookup","auth":"browser session only (a cpt_* control_personal_token is 401 Auth_MissingSession); no authority check — the user_code is the proof, and 5 codes that match nothing in 10 minutes give 429 RateLimit_Exceeded","method":"POST","path":"/key_requests/lookup","summary":"Read the client name, the expiry and the status of a key request by its user_code; 404 KeyRequest_NotFound when no request has the code"},{"absolute_path":"/v1/control/key_requests/approve","auth":"browser session only (a cpt_* control_personal_token is 401 Auth_MissingSession), with the authority of POST /projects/{project_id}/keys on the project_id in the body: a project_key.create grant reaching it, or org_admin authority or above at a binding that reaches it","method":"POST","path":"/key_requests/approve","summary":"Allow a key request for one project. The agent's next poll receives a new project key of that project, one time. 409 KeyRequest_Answered when the request has an answer, 400 KeyRequest_Expired when it expired"},{"absolute_path":"/v1/control/key_requests/deny","auth":"browser session only (a cpt_* control_personal_token is 401 Auth_MissingSession); no authority check — any signed-in holder of the user_code can deny it","method":"POST","path":"/key_requests/deny","summary":"Deny a key request. The agent's next poll gets 403 KeyRequest_Denied"},{"absolute_path":"/v1/control/machine_sign_ins","auth":"none (public: an agent with no credential starts it; 10 an hour for each client address)","method":"POST","path":"/machine_sign_ins","summary":"Start machine sign-in: an agent with no credential asks a person to sign it in. Body {client_name}. Answers device_code, user_code, verification_uri, verification_uri_complete, expires_in (600) and interval (5). The agent names no principal"},{"absolute_path":"/v1/control/machine_sign_ins/token","auth":"none (public: the device_code in the body is the only proof)","method":"POST","path":"/machine_sign_ins/token","summary":"Poll a machine sign-in: 202 while the person has not answered, 200 one time after an allow with the metadata of a new control_personal_token of the principal that approved, the raw cpt_* in personal_token and in its alias token (30-day expiry, the client name as its name), 403 MachineSignIn_Denied after a deny, 429 MachineSignIn_SlowDown when polled sooner than interval, 400 MachineSignIn_Expired for an expired, collected or unknown request. The token can mint more cpt_* tokens and project keys; it cannot call a tool or /mcp, and it cannot use a route that needs a browser session or a fresh sign-in"},{"absolute_path":"/v1/control/machine_sign_ins/lookup","auth":"browser session only (a cpt_* control_personal_token is 401 Auth_MissingSession); no authority check — the user_code is the proof, and 5 codes that match nothing in 10 minutes give 429 RateLimit_Exceeded","method":"POST","path":"/machine_sign_ins/lookup","summary":"Read the client name, the expiry and the status of a machine sign-in by its user_code; 404 MachineSignIn_NotFound when no request has the code"},{"absolute_path":"/v1/control/machine_sign_ins/approve","auth":"browser session only (a cpt_* control_personal_token is 401 Auth_MissingSession); no authority check — the allow binds to the session principal, and the body names no principal","method":"POST","path":"/machine_sign_ins/approve","summary":"Allow a machine sign-in. The agent's next poll receives one control_personal_token of the session principal, one time. 409 MachineSignIn_Answered when the request has an answer, 400 MachineSignIn_Expired when it expired"},{"absolute_path":"/v1/control/machine_sign_ins/deny","auth":"browser session only (a cpt_* control_personal_token is 401 Auth_MissingSession); no authority check — any signed-in holder of the user_code can deny it","method":"POST","path":"/machine_sign_ins/deny","summary":"Deny a machine sign-in. The agent's next poll gets 403 MachineSignIn_Denied and no token"},{"absolute_path":"/v1/control/organizations/{organization_id}/keys","auth":"control_personal_token or session cookie; each key whose every part the caller may list: for a channel part a messaging_waba.list grant reaching {organization_id}, or org_admin authority or above at a binding that reaches it; for a Tools part a project_key.list grant reaching the project, or org_admin authority or above at a binding that reaches it. A caller with neither authority over any key is 403 Control_Forbidden; an organization outside the caller's reach is 404","method":"GET","path":"/organizations/{organization_id}/keys","summary":"List the API keys of the organization, revoked ones included, as metadata only: {id, name, masked (ak_**last4; ww_**last4 for a key a Warta key route minted; ww_ alone for a converted Warta key, which stored no last four characters), organization_id, created_by (null for a key no principal minted), created_at, last_used_at, expires_at, revoked_at, tools: {project_id} or null, channel: {scope, channel, business_address_id, phone_number} or null}. Never a secret or its hash"},{"absolute_path":"/v1/control/organizations/{organization_id}/keys","auth":"control_personal_token or session cookie with the authority that mints each part the body names: for tools a project_key.create grant reaching the project, or org_admin authority or above at a binding that reaches it; for channel a messaging_waba.create grant reaching {organization_id}, or org_admin authority or above at a binding that reaches it","method":"POST","path":"/organizations/{organization_id}/keys","summary":"Mint one API key (issue #2257). Body {name (1-120 characters), expires_at? (RFC 3339; absent mints a key that does not expire), tools?: {project_id}, channel?: {scope: organization | channel (with channel) | business_address (with business_address_id)}}, with at least one part. 201 with the key as the list serves it plus api_key, the raw ak_* value, in this answer ONCE. The key works in Authorization: Bearer and in x-api-key on each route its parts reach: the Tools part on /v1/tools/*, /mcp and the File routes of its project; the channel part on /v1/channels/* and the /v1/messaging routes that take a Warta key, at its scope. It never reaches a route under /v1/control. 400 Control_InvalidInput for no part, a blank name or a project of another organization; 404 Channel_AddressNotFound for an unknown business address. Appends a control.api_key.create security audit event with credential_class api_key and key_id in the mint's transaction"},{"absolute_path":"/v1/control/organizations/{organization_id}/keys/{key_id}","auth":"control_personal_token or session cookie. A rename needs project_key.update (Tools part) and messaging_waba.update (channel part) over each part the key has; adding a part needs project_key.create or messaging_waba.create; removing or replacing a part needs project_key.delete or messaging_waba.delete over that part only. Each grant reaches the project or {organization_id}, or the caller holds org_admin authority or above at a binding that reaches it","method":"PATCH","path":"/organizations/{organization_id}/keys/{key_id}","summary":"Change one API key (issue #2257). Body {name?, tools?, channel?}: tools and channel are each absent (unchanged), null (remove the part) or an object of the mint's shape (set the part). A change that leaves no part revokes the key, and the key keeps its last part. 200 with the key. 409 Control_KeyRevoked for a revoked key; 409 Control_KeyChanged when its parts changed while the request ran; 404 Resource_NotFound for an unknown key or a key of an organization outside the caller's reach. Appends a control.api_key.update security audit event with the parts before and after"},{"absolute_path":"/v1/control/organizations/{organization_id}/keys/{key_id}","auth":"control_personal_token or session cookie with project_key.delete over a Tools part and messaging_waba.delete over a channel part, for each part the key has; each grant reaches the project or {organization_id}, or the caller holds org_admin authority or above at a binding that reaches it","method":"DELETE","path":"/organizations/{organization_id}/keys/{key_id}","summary":"Revoke one API key (issue #2257). 200 with the revoked key, which keeps its parts; a revoked key answers as it is. Appends a control.api_key.revoke security audit event"},{"absolute_path":"/v1/control/projects","auth":"control_personal_token with a project.list grant","method":"GET","path":"/projects","summary":"List projects (optional ?organization_id)"},{"absolute_path":"/v1/control/projects","auth":"control_personal_token with a project.create grant","method":"POST","path":"/projects","summary":"Create a project"},{"absolute_path":"/v1/control/projects/{project_id}","auth":"control_personal_token with a project.read grant","method":"GET","path":"/projects/{project_id}","summary":"Get a project"},{"absolute_path":"/v1/control/projects/{project_id}","auth":"control_personal_token with a project.update grant reaching the project, or org_admin authority or above at a binding that reaches it","method":"PATCH","path":"/projects/{project_id}","summary":"Rename the project (issue #1573): {name}, not blank and at most 120 characters. Answers the renamed project. A name another live project of the organization holds is 409 Control_Conflict, decided after the authority check; the name of a deleted project is free, and the current name answers 200 and changes nothing. A deleted or unknown project is 404 Resource_NotFound. The rename appends a control.project.rename security audit event with the old and the new name in its own transaction"},{"absolute_path":"/v1/control/projects/{project_id}","auth":"control_personal_token with a project.delete grant reaching the project, or org_admin authority or above at a binding that reaches it","method":"DELETE","path":"/projects/{project_id}","summary":"Delete the project (issue #1573). No body. Keeps the project row as a deleted record and keeps its usage. In the same transaction it removes the Tools part from each API key that names the project and revokes each key with no part left (issue #2257), deletes every connected account (after the provider revoke rule of #1565), file (bytes first), execution log, project capability pin and retention setting, detaches every WhatsApp Business Account, revokes every subscriber endpoint registered with its project_id and every project-scope membership, revokes every pending Project Invitation of the project (issue #1965), and appends a control.project.delete security audit event with the counts. Answers 200 after the commit with the project, deleted_at, api_keys {tools_part_removed, revoked} (the key ids) and counts {project_keys_revoked, project_keys_tools_part_removed, connected_accounts_deleted, files_deleted, execution_logs_deleted, wabas_detached, subscriptions_revoked, memberships_revoked, invitations_revoked}. A deleted project answers 404 Resource_NotFound on every control-plane route, a key with only its Tools part answers 401 Auth_InvalidCredential, a key that kept a channel part still reaches the channel API, and its name is free for a new project. A member the delete leaves with no active and no suspended membership in the organization loses the connected accounts filed under its id in the organization's other projects (issue #1855)"},{"absolute_path":"/v1/control/projects/{project_id}/keys","auth":"control_personal_token with a project_key.list grant reaching the project, or org_admin authority or above at a binding that reaches it","method":"GET","path":"/projects/{project_id}/keys","summary":"List the API keys whose Tools part names the project, as metadata only (name, masked, created/last-used/expiry/revoked). A key with a channel part as well is listed here too (issue #2257)"},{"absolute_path":"/v1/control/projects/{project_id}/keys","auth":"control_personal_token with a project_key.create grant reaching the project, or org_admin authority or above at a binding that reaches it","method":"POST","path":"/projects/{project_id}/keys","summary":"Mint one named ak_* API key with a Tools part of the project and no channel part; the raw key appears in this response ONCE and is never recoverable. POST /organizations/{organization_id}/keys mints a key with both parts"},{"absolute_path":"/v1/control/projects/{project_id}/keys/{key_id}","auth":"control_personal_token with a project_key.delete grant reaching the project, or org_admin authority or above at a binding that reaches it","method":"DELETE","path":"/projects/{project_id}/keys/{key_id}","summary":"Remove the project's Tools part from one API key (separate from minting — that separation is overlap rotation). A key with no other part is revoked; a key with a channel part keeps it and still reaches the channel API (issue #2257). Answers the key as a project key, with revoked_at set"},{"absolute_path":"/v1/control/memberships","auth":"control_personal_token with a membership.list grant","method":"GET","path":"/memberships","summary":"List memberships (scope filters). A membership whose principal has a scheduled deletion also carries principal_deletion_scheduled_for, the deletion date (issue #1581)"},{"absolute_path":"/v1/control/memberships","auth":"control_personal_token with a membership.grant grant reaching the target scope, PLUS the delegation guard of contract #34 — the granted role's grants must be ones the caller already holds, and the caller must hold at least one the grantee does not. Beside the guard, the owner rule (issue #1691): an Organization owner grants org_owner at its own organization's scope, to a human principal only (a service principal is 400 Control_InvalidInput), after a fresh sign-in: a browser session sends the header x-fresh-id-token with an ID token of the session principal whose auth_time is at most 5 minutes old (no header or a stale token is 401 Principal_ReauthenticationRequired, another identity 403 Principal_IdentityMismatch, a cpt_* control_personal_token 403 Control_Forbidden)","method":"POST","path":"/memberships","summary":"Grant a membership"},{"absolute_path":"/v1/control/memberships/{membership_id}","auth":"control_personal_token with a membership.read grant reaching the membership's own scope","method":"GET","path":"/memberships/{membership_id}","summary":"Get a membership"},{"absolute_path":"/v1/control/memberships/{membership_id}","auth":"control_personal_token with a membership.update grant reaching the membership's own scope, PLUS the delegation guard of contract #34 — status-only edits included. Beside the guard, the owner rule (issue #1691): an Organization owner changes an org_owner membership of its own organization, its own included, and makes a member org_owner (a human principal only); an edit that starts or ends an active org_owner membership then needs a fresh sign-in: a browser session sends the header x-fresh-id-token with an ID token of the session principal whose auth_time is at most 5 minutes old (no header or a stale token is 401 Principal_ReauthenticationRequired, another identity 403 Principal_IdentityMismatch, a cpt_* control_personal_token 403 Control_Forbidden). The last active org_owner is 409 Control_Conflict. An edit of an organization-scope membership whose result is revoked does what DELETE does: it revokes the active and suspended project-scope memberships of that principal in that organization in the same transaction (issue #1905; their ids are in project_membership_ids of the audit event), deletes its team places there when no active and no suspended membership stays, and sends the same mail. An edit to suspended changes no project-scope membership","method":"PATCH","path":"/memberships/{membership_id}","summary":"Update a membership"},{"absolute_path":"/v1/control/memberships/{membership_id}","auth":"control_personal_token with a membership.revoke grant reaching the membership's own scope, PLUS the delegation guard of contract #34. Beside the guard, the owner rule (issue #1691): an Organization owner revokes an org_owner membership of its own organization, its own included; revoking an active one needs a fresh sign-in: a browser session sends the header x-fresh-id-token with an ID token of the session principal whose auth_time is at most 5 minutes old (no header or a stale token is 401 Principal_ReauthenticationRequired, another identity 403 Principal_IdentityMismatch, a cpt_* control_personal_token 403 Control_Forbidden). The last active org_owner is 409 Control_Conflict. Beside both, the self rule (issue #1852): a principal revokes its own active membership at any organization or project scope, except a built-in org_owner one, where the grant or the guard refuses it; that revoke needs the same fresh sign-in. A revoke of an organization-scope membership, whoever revokes it, also revokes, in the same transaction, the active and suspended project-scope memberships of that principal in that organization (issue #1905; their ids are in project_membership_ids of the audit event). A revoke that leaves the principal with no active and no suspended membership in the organization deletes its team places there in the same transaction, and deletes the connected accounts filed under its id there after the commit (issue #1855). With the member-removed mail on, a revoke by another principal of an active organization-scope membership that is not an org_owner membership emails the removed member","method":"DELETE","path":"/memberships/{membership_id}","summary":"Revoke a membership"},{"absolute_path":"/v1/control/roles","auth":"control_personal_token with a role.list grant reaching ?organization_id=","method":"GET","path":"/roles","summary":"List role definitions (?organization_id)"},{"absolute_path":"/v1/control/roles","auth":"control_personal_token with a role.create grant reaching the organization, PLUS the delegation guard of contract #34 — the bundle must be one the caller's own grants cover","method":"POST","path":"/roles","summary":"Create a role definition"},{"absolute_path":"/v1/control/roles/{organization_id}/{role_id}","auth":"control_personal_token with a role.read grant reaching {organization_id}","method":"GET","path":"/roles/{organization_id}/{role_id}","summary":"Get a role definition"},{"absolute_path":"/v1/control/roles/{organization_id}/{role_id}","auth":"control_personal_token with a role.update grant reaching {organization_id}, PLUS the delegation guard of contract #34","method":"PATCH","path":"/roles/{organization_id}/{role_id}","summary":"Update a role definition"},{"absolute_path":"/v1/control/roles/{organization_id}/{role_id}","auth":"control_personal_token with a role.delete grant reaching {organization_id}","method":"DELETE","path":"/roles/{organization_id}/{role_id}","summary":"Archive a role definition"},{"absolute_path":"/v1/control/principals","auth":"control_personal_token (platform_operator only)","method":"GET","path":"/principals","summary":"List installation principals"},{"absolute_path":"/v1/control/principals/me","auth":"control_personal_token; no authority check — a caller changes its OWN display_name and nobody else's","method":"PATCH","path":"/principals/me","summary":"Change the display name of the CALLING principal (issue #1506): {display_name}, not blank and at most 120 characters. Answers the principal. There is no principal id in the path or the body, by construction. The console shows this name in the account menu and in member lists, and a later sign-in keeps it. The change appends a control.principal.rename security audit event with the old and the new name in its own transaction"},{"absolute_path":"/v1/control/tokens","auth":"control_personal_token (platform_operator only)","method":"GET","path":"/tokens","summary":"List personal-token metadata (never raw tokens)"},{"absolute_path":"/v1/control/tokens","auth":"control_personal_token or a browser session; no authority check — a caller mints its OWN credential","method":"POST","path":"/tokens","summary":"Mint a control personal token for the CALLING principal; the raw cpt_* appears in this response ONCE and is never recoverable. There is no target-principal parameter, by construction: that is what keeps a route handing out long-lived secrets from becoming an impersonation surface. A first cpt_* comes from a browser Session or from an approved machine sign-in (see /docs/api). Optional body {name}: 1 to 60 characters after trim, no control characters. The console shows the name in the account menu's token list."},{"absolute_path":"/v1/control/tokens/{token_id}","auth":"control_personal_token; revoking the CALLER's own token needs no grant, and revoking another principal's needs an installation.create grant at the installation scope","method":"DELETE","path":"/tokens/{token_id}","summary":"Revoke a personal token"},{"absolute_path":"/v1/control/principals/me/tokens","auth":"control_personal_token or a browser session; no authority check — a caller reads or revokes its OWN tokens and nobody else's","method":"GET","path":"/principals/me/tokens","summary":"List the personal tokens of the CALLING principal that are not revoked and not expired, newest first, as metadata with the name (never raw tokens). There is no principal id in the path, by construction"},{"absolute_path":"/v1/control/principals/me/tokens","auth":"control_personal_token or a browser session; no authority check — a caller reads or revokes its OWN tokens and nobody else's","method":"DELETE","path":"/principals/me/tokens","summary":"Revoke every live personal token of the CALLING principal, the token that sends the request included. Answers {revoked}, the count. A revoked token is refused at its next request. The revocation appends a control.token.revoke_all security audit event with the count in its own transaction"},{"absolute_path":"/v1/control/audit_events","auth":"control_personal_token (platform_operator only)","method":"GET","path":"/audit_events","summary":"Cursor-paginated audit read. REDACTED by default: exact values need reveal=exact AND a reason, and both are recorded. Reading the trail APPENDS to the trail — one generic request event and one accountability event — and both appear in later pages"},{"absolute_path":"/v1/control/audit_events/verify","auth":"control_personal_token (platform_operator only)","method":"POST","path":"/audit_events/verify","summary":"Recompute the tamper-evidence chain and compare it against every stored checkpoint. A POST because it WRITES: a verification that reproduces every stored root appends a new checkpoint covering what it read, and one that does not appends nothing — re-anchoring a broken chain would make every later verification agree with the tampering. The checkpoint it writes is signed with an Ed25519 key that the database does not hold, the worker writes one signed checkpoint each day, and the signed copy of the newest one in object storage (the witness) is compared too. The status is verified only over a signature that it checked; the answer names checkpoints_unsigned, checkpoints_unchecked, broken_reason and witness"},{"absolute_path":"/v1/control/audit_events/exports","auth":"control_personal_token (platform_operator only)","method":"POST","path":"/audit_events/exports","summary":"Start an export job over the same filter, disclosure and reason parameters the read takes, given in the QUERY. The accountability event lands before the job is acknowledged, so an export nobody can account for cannot be started"},{"absolute_path":"/v1/control/audit_events/exports/{job_id}","auth":"control_personal_token (platform_operator only)","method":"GET","path":"/audit_events/exports/{job_id}","summary":"Get one export job's state"},{"absolute_path":"/v1/control/audit_events/exports/{job_id}/download","auth":"control_personal_token (platform_operator only)","method":"POST","path":"/audit_events/exports/{job_id}/download","summary":"Claim the export bytes, with the content hash in x-audit-export-content-hash. A POST and not a GET because the claim is SINGLE-USE and therefore a mutation"},{"absolute_path":"/v1/control/exports/contacts","auth":"org_admin authority or above at ?organization_id=, or BOTH an explicit contact.list AND an explicit conversation.list grant (session cookie or cpt_*)","method":"GET","path":"/exports/contacts","summary":"Export one Organization's Contact metadata and Sambungapi-native Conversation evidence as one JSON document (?organization_id= required). Since #2199 it holds every channel: each Contact carries id (its ct_ id), contact (null for a Contact with no phone number and no WhatsApp user id), identities, profile_name, display_name, app_contact_name, labels and note; each Conversation carries its channel, its WABA and business phone number on WhatsApp or its business_address_id and platform_id on another channel, assignment, state and stamps, and a group space's Conversations are under group_conversations; each message carries its stable id, wamid, direction, type, text, media METADATA, reaction and template, oldest first. There is no installation_id parameter — the installation comes from the token — and an organization the caller's own bindings do not reach is refused, so the export cannot be pointed at another tenant. It excludes media BYTES, connected-account credentials, Warta keys, project keys, control-plane tokens, signing secrets and subscriber secrets: the document's key set is a closed allowlist and the build refuses rather than emits when anything else appears. A media id is evidence that a media message happened and NOT a download link — Meta expires a handle 30 days after the message that carried it and sambungapi never stored the bytes. Inbound evidence Meta reported with no sender is exported under unattributed_messages rather than dropped. Every request appends an attributable audit event carrying the counts and no message bodies, and the export fails closed when that event cannot be appended. The document is assembled in memory and answered in one response, so it is BOUNDED: an Organization holding more than SAMBUNGAPI_CONTACT_EXPORT_MAX_MESSAGES messages (250000 by default) is refused 413 Control_ExportTooLarge before a row is read, and the refusal names the count, the ceiling and the variable. Budget about 415 bytes of document per message. During a Channel stop (issue #1944) the document leaves out the stopped channel's Contacts, identities, Conversations, messages and Send failures and keeps every other channel's; since #2199 a stop of WhatsApp is no longer a 409"},{"absolute_path":"/v1/control/exports/organization","auth":"org_admin authority or above at ?organization_id= — an Organization admin, an Organization owner or a platform_operator; no custom-role grant admits a caller (session cookie or cpt_*)","method":"GET","path":"/exports/organization","summary":"Export one whole Organization as one JSON document (?organization_id= required): data.bundle is the bundle sambungapi-lifecycle export prints — the Organization, its Projects (a deleted Project is listed with deleted: true), project keys as their non-secret identity, Auth configs, Connected accounts, Execution logs as outcome metadata, File metadata with no File content, the daily usage rollups, members, Invitations, Teams, WhatsApp Business Accounts, message templates, proactive runs, consent evidence, Warta keys and Subscriptions, and since #2199 business_addresses (each business address that is not a business phone number: id, channel, platform_id, name, status, disconnected_at, created_at, and no token), each with a count — and data.contacts is the document GET /exports/contacts answers. An Organization outside the caller's reach gets the answer GET /exports/contacts gives the same caller (404 Resource_NotFound), and a member below the bar gets 403 Control_Forbidden. The route counts the Organization's messages plus its Execution logs before it reads, and refuses past SAMBUNGAPI_ORGANIZATION_EXPORT_MAX_ROWS (145000 by default; budget about 7.8 KiB of peak memory per Execution log and 4.6 KiB per message) with 413 Control_ExportTooLarge naming the count, the ceiling and the variable. A process builds one export at a time: this route and GET /exports/contacts share one permit, and a request waits for it inside its request budget. Every request appends one Security audit event with action control.organization.export — a refusal with the status it serves, a served export with export_version and the counts of both documents and no message body — and the export fails closed when that event cannot be appended. During a Channel stop of WhatsApp (issue #1944) the bundle leaves out message_templates, proactive_runs and consent_evidence, the contacts document leaves out the WhatsApp Contacts, identities and Conversations and keeps every other channel's (#2199), and bundle.channel_stops: [{channel, stopped_at}] names the stop; with no stop it is an empty array. sambungapi-lifecycle export writes the whole document for a platform operator"},{"absolute_path":"/v1/control/execution_logs","auth":"project_member authority or above at a binding that reaches ?project_id= (the org_owner and org_admin of its organization, its project_admin and project_member, and every platform_operator), or an explicit execution_log.list grant at that project (session cookie or cpt_*). An unknown ?project_id= is 404 and a project the caller cannot reach is 403, as on GET /projects/{project_id}","method":"GET","path":"/execution_logs","summary":"One page of a project's Execution log, newest first (?project_id= required). Filters: status (succeeded|failed|refused: refused when provider_outcome.refusal exists, failed when provider_outcome.successful is false, succeeded otherwise), toolkit (exact; a row written before the toolkit was recorded matches none), tool (the slug in path, without a #step<n> or #mcp suffix), api_key_id, user_id, since (RFC3339; with no since the server picks a 30-day window), limit (default 100, capped at 500) and cursor (meta.next_cursor); a malformed one is 400 Control_InvalidInput. Each row carries toolkit, user_id, connected_account_id, api_key_id and api_key ({api_key_id, name, last4, revoked}, or null when the row names no key), and provider_outcome holds only successful, refusal, error_kind, provider_status and duration_ms: never the provider's response (data) or error text (error), for any reader. meta carries next_cursor while another page exists, and api_keys: the label of each project key that a row in the window names"},{"absolute_path":"/v1/control/execution_logs/{log_id}","auth":"project_member authority or above at a binding that reaches ?project_id= (the org_owner and org_admin of its organization, its project_admin and project_member, and every platform_operator), or an explicit execution_log.read grant at that project (session cookie or cpt_*). An unknown ?project_id= is 404 and a project the caller cannot reach is 403, as on GET /projects/{project_id}","method":"GET","path":"/execution_logs/{log_id}","summary":"Get one Execution log row (?project_id= required), in the projection of the listing: provider_outcome without data and without error, and the row's api_key label"},{"absolute_path":"/v1/control/usage","auth":"org_admin authority or above at ?organization_id=, or an explicit usage.read grant; with &project_id= also any binding at project_member or above that reaches that project (session cookie or cpt_*)","method":"GET","path":"/usage","summary":"Usage and quota summary for one organization (?organization_id= required, optional &from= &to= as YYYY-MM-DD, &granularity=day|month defaulting to day, &project_id=). A daily range spans fewer than 92 days and a monthly range at most 13 calendar months; a month is summed over its UTC days on read. Counts only, no payloads and no credentials: the accepted total with its success/failure split, the periods[] rows at the requested grain, the per-project toolkit and Project-key breakdowns, each project's first_succeeded_on (the first UTC day it completed a tool execution, over all time and unchanged by the range, or null when it never has), the organization message counts by phone number, channel and Meta pricing category and by period at the requested grain (messages.periods[]), and the quota standing for the LAST day of the window — the only day whose budget is still spendable. With &project_id= the whole response describes that one project and carries no messages object. Read from the rollup tables and not from execution_logs, whose 30-day window is purged"},{"absolute_path":"/v1/control/pins","auth":"control_personal_token with a permission_binding.list grant reaching ?organization_id= (only the platform_operator preset carries permission_binding.*; any other role needs an explicit custom grant)","method":"GET","path":"/pins","summary":"List capability pins (?organization_id= required, optional &project_id= and &intent_id=)"},{"absolute_path":"/v1/control/pins","auth":"control_personal_token with a permission_binding.create grant reaching body.organization_id (and body.project_id when given)","method":"POST","path":"/pins","summary":"Pin one intent to one implementation: {organization_id, intent_id, implementation_id, optional project_id}. The pair is validated against the EMBEDDED capability vocabulary before authorization, and the write is an UPSERT on the scope"},{"absolute_path":"/v1/control/pins/{intent_id}","auth":"control_personal_token with a permission_binding.update grant reaching the scope, taken from body.organization_id and falling back to ?organization_id=","method":"PATCH","path":"/pins/{intent_id}","summary":"Repoint a pin to a different implementation. The scope is read from the body first and from the query second, so either spelling works"},{"absolute_path":"/v1/control/pins/{intent_id}","auth":"control_personal_token with a permission_binding.delete grant reaching ?organization_id= (and &project_id= when given)","method":"DELETE","path":"/pins/{intent_id}","summary":"Remove a pin (?organization_id= required, optional &project_id=)"},{"absolute_path":"/v1/control/proactive/consent","auth":"org_admin authority or above at ?organization_id=, or an explicit contact.list grant (session cookie or cpt_*)","method":"GET","path":"/proactive/consent","summary":"One organization's Consent evidence (?organization_id= required, optional &contact= &waba_id= &category=). Scoped per Contact, WABA and Template category, because a utility opt-in must not authorize marketing and a permission given to one business account is not a permission on another. Every row carries its source, capture time, policy reference and resolved state — present, withdrawn, or missing. During a Channel stop of WhatsApp (issue #1944) this route and every other /proactive route of the organization answer 409 Proactive_ChannelStopped, after the authorization: Consent evidence and the recipients of a run are the channel's data"},{"absolute_path":"/v1/control/proactive/consent","auth":"org_admin authority or above at body.organization_id, or an explicit contact.update grant (session cookie or cpt_*)","method":"POST","path":"/proactive/consent","summary":"Record what a Contact allowed: {organization_id, contact, waba_id, category, source, captured_at, policy_reference}, all required. 200 and an UPSERT on the scope, so re-consent after a withdrawal is the same row granted again — but only an agreement given AFTER the withdrawal ends it: on a withdrawn scope, a captured_at not later than the stored withdrawn_at is 409 Proactive_ConsentPredatesWithdrawal and changes nothing. Each successful record writes one Security audit event (consent.record) that names the channel identity id, the WABA, the category and captured_at, and no phone number. captured_at is the recorder's, because consent is routinely captured elsewhere and imported — a FUTURE timestamp is refused, which is the one direction that could forge freshness. The contact must already exist in the organization and the WABA must be its own: an arbitrary phone list is not consentable here and stays available only through the raw WhatsApp gateway"},{"absolute_path":"/v1/control/proactive/consent/withdraw","auth":"org_admin authority or above at body.organization_id, or an explicit contact.update grant (session cookie or cpt_*)","method":"POST","path":"/proactive/consent/withdraw","summary":"Stamp one scope withdrawn: {organization_id, contact, waba_id, category}. Its own route and NOT a DELETE, because the row stays — a deleted row and a row that never existed read the same to every later question, and 'this person opted out on 3 March' is precisely what a compliance question asks for. Idempotent: a second withdrawal keeps the first instant. Each successful withdrawal writes one Security audit event (consent.withdraw) that names the channel identity id, the WABA, the category and withdrawn_at, and no phone number"},{"absolute_path":"/v1/control/proactive/consent/batch","auth":"org_admin authority or above at body.organization_id, or an explicit contact.update grant (session cookie or cpt_*)","method":"POST","path":"/proactive/consent/batch","summary":"Record many agreements on one WABA: {organization_id, waba_id, records[]}, where each record is {contact, category, source, captured_at, policy_reference} and unknown keys are refused. 1 to 10000 records, in a body of at most 20480000 bytes. Each record is its own write with the validation, permission and rules of POST /proactive/consent, and a refused record changes nothing while the others still write. 200 with data {counts: {recorded, refused}, rows: [{index, contact, outcome, reason, message}]}: outcome is recorded or refused, and reason is invalid_input, contact_not_found (the number is not a Contact of this organization, and no Contact is added) or predates_withdrawal (the scope is withdrawn and captured_at is not later than withdrawn_at). Each recorded row writes one consent.record Security audit event, and a refused row writes none. The whole request is refused, and nothing is written, for more than 10000 records (400 Proactive_InvalidInput) and for a WABA that is not this organization's (404 Proactive_WabaNotFound)"},{"absolute_path":"/v1/control/proactive/policy-check","auth":"org_admin authority or above at body.organization_id, or an explicit contact.read grant (session cookie or cpt_*)","method":"POST","path":"/proactive/policy-check","summary":"Evaluate the proactive policy layer over a proposed audience and QUEUE NOTHING: {organization_id, waba_id, phone_number_id, template_id, mode, contacts[]}. mode is campaign or event_triggered, and an event-triggered send needs an existing Conversation on that business phone number while a campaign does not. The body has no phone-list field and denies unknown keys, so an arbitrary phone list is a 400 rather than a send. Answers a per-recipient outcome (eligible, or suppressed with a stable reason), the template's Meta category as the consent scope, and the admission decision. Admission FAILS CLOSED — it pauses when Meta's Business Portfolio limit is unknown, stale or exhausted, when the organization's budget is spent, or when Meta's send-health signal is restricted or unread. contacts[] names at most 10000 entries on POST /proactive/policy-check and at most 10000 on POST /proactive/runs: one bound, so every audience a run can take can be previewed first. The bound counts entries before duplicates collapse, and a longer list is 400 Proactive_InvalidInput naming the bound, before the audience is read. The preview judges every entry and never a sample"},{"absolute_path":"/v1/control/proactive/runs","auth":"org_admin authority or above at body.organization_id, or an explicit contact.update grant (session cookie or cpt_*)","method":"POST","path":"/proactive/runs","summary":"Create a Campaign or an Event-triggered send and queue what admission allows: {organization_id, waba_id, phone_number_id, template_id, mode, contacts[], idempotency_key, cost_acknowledged, scheduled_for?}. 201 on a fresh creation, 200 with the existing run when the same idempotency_key repeats the SAME request, 409 Proactive_IdempotencyKeyReused when it names a different one. mode=event_triggered targets exactly one Contact and needs an existing Conversation on that business phone number; mode=campaign may reach many. The audience is a FIXED snapshot of Organization Contacts written in the run's own transaction -- there is no phone-list field, unknown keys are refused, and a number that is not a Contact is a 404 rather than a send. cost_acknowledged must be true (#945 Q8) and the run reports the same estimate POST /proactive/policy-check previews: a chargeable-recipient COUNT with explicit uncertainty, never an amount. scheduled_for must be in the future and gives a one-time schedule; omit it to launch immediately. Admission is evaluated HERE for both, including a scheduled run, and a pause is 409 Proactive_AdmissionPaused with reason=<slug> on errors[] -- work is never queued behind a pause. contacts[] names at most 10000 entries on POST /proactive/policy-check and at most 10000 on POST /proactive/runs: one bound, so every audience a run can take can be previewed first. The bound counts entries before duplicates collapse, and a longer list is 400 Proactive_InvalidInput naming the bound, before the audience is read. The preview judges every entry and never a sample"},{"absolute_path":"/v1/control/proactive/runs","auth":"org_admin authority or above at ?organization_id=, or an explicit contact.list grant (session cookie or cpt_*)","method":"GET","path":"/proactive/runs","summary":"One page of an organization's proactive runs, newest first (?organization_id= required, optional &mode= &state= &waba_id= &created_after= &created_before= &limit= &before=; #1980). limit defaults to 50 (also for 0) and is clamped to 200; created_after and created_before are inclusive RFC 3339 bounds on created_at; before is the opaque meta.next_cursor of the previous page (base64url of <epoch-micros>:<pxr_ id>). A malformed limit, bound or cursor, or created_after later than created_before, is 400 Proactive_InvalidInput. meta carries request_id, total (the length of this page) and next_cursor only while another page exists. Every row carries the run identifier, the account, the template snapshot, the policy_version its audience was judged under, the operator-verb stamps, held_by and held_since, and the per-state recipient counts. held_by is meta_reonboarding and held_since the start of the account's Reonboarding (RFC 3339 in UTC) on a scheduled or running run whose account Meta reonboards: the executor then claims no recipient of the run, the recipients stay queued, and the first sweep after the end continues it. Otherwise both are null (#1844). Filters pass through as strings: a state this build does not know answers an empty list rather than a 400"},{"absolute_path":"/v1/control/proactive/runs/send-again","auth":"org_admin authority or above at body.organization_id, or an explicit contact.update grant (session cookie or cpt_*)","method":"POST","path":"/proactive/runs/send-again","summary":"Send again for the failed recipients of many runs: {organization_id, since, until?, mode?, waba_id?, preview?} (#1959, decided in #1871; until, the bound of 100 and admission before the bound since #2241, decided in #2093). since is an RFC 3339 instant, required and not in the future; until is an optional RFC 3339 instant, not before since (400 Proactive_InvalidInput), and may be in the future; mode is campaign or event_triggered, both when omitted. Takes each running or completed run, narrowed by mode and waba_id, with a recipient that the Send again of one run takes and whose failed_at is at or after since and at or before until, and puts back only those recipients; moves a completed run to running. Admission is checked once for each account of the window before the bound; a run whose account admission pauses does not change, is listed in skipped, and is not counted in remaining. Of the other runs, at most 100 per call, the oldest matching failed_at first. 200 with data = {runs, recipients, remaining, remaining_recipients, skipped: [{run_id, reason}]}: remaining counts the matching runs past the bound and remaining_recipients their eligible recipients. Set until once and call again until remaining is 0. preview: true changes nothing and answers the same object. Without preview, no matching run is 409 Proactive_RunTransitionRefused with reason=nothing_to_send_again. A 504 Request_Timeout leaves each run whole: the runs that moved stay moved, and the next call takes the rest"},{"absolute_path":"/v1/control/proactive/runs/{run_id}","auth":"org_admin authority or above at ?organization_id=, or an explicit contact.read grant (session cookie or cpt_*)","method":"GET","path":"/proactive/runs/{run_id}","summary":"One run's durable status (?organization_id= required): its state, the template snapshot, every lifecycle stamp, held_by and held_since (meta_reonboarding and the start of the account's Reonboarding while Meta reonboards the account of a scheduled or running run, else null and null; #1844), and the per-state recipient counts. A run in another organization answers the same 404 as one that does not exist, so a run id is never a cross-tenant existence oracle"},{"absolute_path":"/v1/control/proactive/runs/{run_id}/recipients","auth":"org_admin authority or above at ?organization_id=, or an explicit contact.read grant (session cookie or cpt_*)","method":"GET","path":"/proactive/runs/{run_id}/recipients","summary":"Every recipient's own outcome (?organization_id= required): queued, suppressed, sent, delivered, read or failed, with the stable suppression or failure reason (reonboarding_expired: a held recipient of an AUTHENTICATION run, 15 minutes after the run was due during Meta's Reonboarding of the account; #1844), Meta's wamid where one exists, the attempt count, and the billable evidence Meta supplied. A refusal is final (#1889): the executor never sends a failed recipient again by itself (failure slugs include send_outcome_unknown, a send Meta may have received, and credential_unreadable, a stored credential Sambungapi cannot read). `resumable` is true for a queued recipient with a free slot and for a failed recipient that Resume or Send again would put back in the queue, so a delivered recipient is never resumable and neither is a failed one whose dispatch slot is still held, an opted-out one, or a 131049 one inside its 24 hours. data is an OBJECT -- run state, counts, recipients[] and a billable summary -- because the envelope's meta is frozen at {request_id, total}"},{"absolute_path":"/v1/control/proactive/runs/{run_id}/launch","auth":"org_admin authority or above at body.organization_id, or an explicit contact.update grant (session cookie or cpt_*)","method":"POST","path":"/proactive/runs/{run_id}/launch","summary":"Start a scheduled run now: {organization_id}. Idempotent by construction rather than by a key -- the state machine maps running --launch--> running, so a second launch is the same state and no second send happens. Admission is re-checked, because the messaging-limit observation that admitted the run at creation may have aged out since; a pause is 409 Proactive_AdmissionPaused"},{"absolute_path":"/v1/control/proactive/runs/{run_id}/pause","auth":"org_admin authority or above at body.organization_id, or an explicit contact.update grant (session cookie or cpt_*)","method":"POST","path":"/proactive/runs/{run_id}/pause","summary":"Stop claiming new work: {organization_id}. Recipients keep the outcomes they have, and a pause that lands between the executor's selector and its claim still stops the send because the claim re-reads the run state inside its own transaction. Deliberately NOT gated on admission: an account whose limit expired must still be stoppable"},{"absolute_path":"/v1/control/proactive/runs/{run_id}/resume","auth":"org_admin authority or above at body.organization_id, or an explicit contact.update grant (session cookie or cpt_*)","method":"POST","path":"/proactive/runs/{run_id}/resume","summary":"Return a paused run to work: {organization_id}. A resume puts back in the queue the failed recipients that Send again would take (#1889) and NEVER a delivered one -- a partial unique index at rest is what makes a second send impossible rather than merely unoffered; a resume that finds none still resumes. A run that still carries a future scheduled_for resumes to scheduled, not to running. Admission is re-checked (account_not_connected first, on an EXPIRED or ACCESS_LOST account)"},{"absolute_path":"/v1/control/proactive/runs/{run_id}/send-again","auth":"org_admin authority or above at body.organization_id, or an explicit contact.update grant (session cookie or cpt_*)","method":"POST","path":"/proactive/runs/{run_id}/send-again","summary":"Send the failed recipients of a running or completed run again: {organization_id} (#1889). Puts back in the queue each failed recipient with no unresolved send and a retryable failure (not send_outcome_unknown, not recipient_opted_out), except a recipient with Meta code 130472, a 131049 recipient that failed less than 24 hours ago, a recipient whose Consent evidence is now withdrawn or missing, and the recipient of an AUTHENTICATION run more than 15 minutes after it was due. Moves a completed run to running. Admission is re-checked. 409 Proactive_RunTransitionRefused with reason=run_already_terminal on a cancelled run, run_transition_not_applicable on a scheduled or paused run, and nothing_to_send_again when no recipient matches"},{"absolute_path":"/v1/control/proactive/runs/{run_id}/cancel","auth":"org_admin authority or above at body.organization_id, or an explicit contact.update grant (session cookie or cpt_*)","method":"POST","path":"/proactive/runs/{run_id}/cancel","summary":"End the run: {organization_id}. Terminal -- no recipient of this run is ever dispatched again, including recipients that never were. Idempotent on its own terminal state. It does NOT undo what was already sent: #945 refuses all-or-nothing rollback, so recipients that reached Meta keep their outcomes and their billable evidence. Not gated on admission, for the pause route's reason"}],"surface":"control","version":"v1"},"meta":{"request_id":"req_LMeUN2lMtbAU7uGG","vocabulary_version":"584af6dde90e"}}