# Xaere API v1

Machine-readable integrator contract: [OpenAPI 3.1](https://docs.xaere.io/openapi-v1.json).
It intentionally excludes private Docker control routes and platform-operator
credential endpoints.

`GET /v1/resolve/{broadcast_token}` and `GET /v1/keys` are receiver-facing.
They do not reveal organisation data. Resolution returns an intent only after
Core verifies the immutable signed payload, its expiry and revocation state.
SDK 0.3 and newer request `X-Xaere-Resolution-Proof: ed25519-v1`; Core then
adds a two-minute `resolution_proof` signed with its current Ed25519 key. The
explicit negotiation keeps the strict 0.2 preview response compatible while
making the new SDK fail closed if the envelope is absent. Its base64url
`payload` is the exact JSON
bytes binding the XR identifier, intent, policy, optional Audience proof,
timestamps and key identifier. Receiver SDKs verify those bytes locally with
the exactly matching `/v1/keys` key and compare the signed values to the outer
response before exposing an intent; `signature_status` alone is not trusted.
Resolution is rate-limited by an ephemeral Redis counter keyed with a one-way
address digest. It never creates a device or account identifier; exceeding the
configured short window returns `rate_limited` (HTTP 429).
The Flutter receiver exposes public resolution failures through a closed local
enum: unknown and revoked Codes intentionally share `codeUnavailable`, expiry
is `expired`, and only rate limiting or a temporary HTTP 5xx service failure is
retryable. Server-provided error messages are never copied into host UI state.

Machine-readable JSON Schema 2020-12 contracts are published under
`https://docs.xaere.io/schemas/`: the immutable signed payload
(`xr-code-v1`), issuance response (`xr-code-issued-v1`), tenant-scoped Code
detail (`xr-code-detail-v1`), receiver resolution (`xr-resolution-v1`) and
minimised Audience event (`audience-event-v1`).

Administrative routes use a Keycloak access token once OIDC is enabled. Core
accepts only a signed, unexpired token issued to the console client with an
explicit `email_verified: true` claim. It uses the token subject plus its own
`organization_members` table for every tenant operation; a realm role alone
never grants access to another organisation. Organisation owners and admins,
organisation creation, and `xaere_platform_admin` routes additionally require
a signed LoA 2 (`acr=2`) or OTP AMR claim from Keycloak. A password-only token
receives `mfa_required` (HTTP 403); the browser must restart the LoA 2 login.
During the loopback-only
migration, a root-held operator token is retained solely for local validation
and is not a public API contract.

## Organisations and projects

- `POST /v1/organizations` creates an organisation and makes the authenticated
  subject its owner. The request body contains `name`; the subject is never
  accepted from a browser payload.
- `POST /v1/projects` accepts `organization_id` and `name`. Owner and admin
  members can create projects.
- `GET /v1/organizations?limit=50&cursor=...` returns only organisations in
  which the authenticated subject is a member. The security-definer database
  function accepts a bounded page and never grants direct cross-tenant table
  access to the API role.
- `GET /v1/projects?organization_id=...&limit=50&cursor=...` is subject to the
  same membership and tenant isolation. Both listings return newest-first
  `{data,next_cursor}` pages of at most 100 records. The opaque cursor retains
  the exact PostgreSQL microsecond boundary and is only navigation state.
- `GET /v1/projects/{project_id}/export?organization_id=...` requires an owner
  or administrator session with MFA and downloads `xaere-project-archive-v1`.
  The bounded repeatable-read archive contains project configuration,
  campaigns, signed XR Codes, non-secret project API-key metadata and Audience
  integration metadata. It never contains API-key/HMAC secrets, raw Audience
  events or measurement tokens; larger exports are routed to support with 422.

## Campaigns

`POST /v1/campaigns` creates an active campaign for one organisation and one
project. It accepts `organization_id`, `project_id`, `name`, and optional
`starts_at` / `ends_at` ISO-8601 boundaries.
`GET /v1/campaigns?organization_id=...&project_id=...&limit=50&cursor=...`
requires the same tenant context and is readable to authorised project
members. It returns at most 100 newest-first campaigns and an opaque
`next_cursor` based on `(created_at, campaign_id)`; timestamp ties are stable
and older pages are loaded only on explicit request. Owners and administrators
may correct an active campaign through
`PATCH /v1/campaigns/{campaign_id}?organization_id=...` with one or more of
`name`, `starts_at` and `ends_at`. This OIDC-only operation requires MFA,
locks the tenant campaign, rejects archived campaigns and records
`campaign.updated` with the previous and current non-secret schedule metadata.
Changing the schedule controls future issuance only: it never rewrites an
already signed Code, its expiry or its revocation state. A duplicate name in
the same project returns `409 campaign_name_conflict`. Owners and administrators
archive a campaign through
`POST /v1/campaigns/{campaign_id}/archive?organization_id=...`.

`GET /v1/campaigns/{campaign_id}/summary?organization_id=...` returns the
campaign plus tenant-scoped Code lifecycle counts: issued, currently active,
expired, revoked and Audience-enabled, together with the first and last issue
timestamps. Expiry is classified at read time. The query joins Codes only when
both campaign and organisation match, then applies the campaign project's
`codes:read` authorization; it never exposes intents, signatures or Audience
credentials.

`POST /v1/campaigns/{campaign_id}/stop?organization_id=...` is the emergency
campaign stop. In one transaction it locks the campaign, archives it to block
new issuance, revokes every unexpired Code whose status is still active, and
records `campaign.stopped` with the affected count in the tenant audit log.
The operation requires project `codes:write` and an owner/admin role for human
users. It is idempotent: a repeated request leaves the campaign archived and
returns zero newly revoked Codes. Expired Codes keep their historical expired
classification rather than being rewritten as revoked.

`campaign_id` is optional when issuing a Code or batch, but if supplied it is
signed into every Code and must reference an active campaign of the same
organisation and project whose schedule is currently open. An archived or
out-of-window campaign cannot issue new Codes; existing Codes retain their
normal expiry and revocation lifecycle.

`POST /v1/codes/revoke-batch?organization_id=...` accepts a `project_id` and
one to 100 unique `xr_ids`. It locks the complete tenant/project selection and
fails closed with `404` before changing anything if one identifier is missing
or belongs to another boundary. It then revokes only active, unexpired Codes
in one transaction and records a single `code.batch_revoked` audit entry with
the requested and changed counts. Repeating the same selection is safe and
returns the Codes as unchanged. Human owners/administrators require MFA;
server keys require project-scoped `codes:write`.

The Codes Console can build this explicit selection from active Codes already
listed for the current project, including after a page reload. It caps the
selection at 100 and uses the same identifiers for atomic revocation or for a
scoped WAV ZIP; changing organisation or project clears the selection.

`GET /v1/codes/{xr_id}?organization_id=...&project_id=...` requires
`codes:read` within the Code's own tenant project. `project_id` is optional for
backward compatibility; when supplied, Core returns the same closed `404` if
the exact Code is not inside that project. It returns the complete immutable signed XR Code,
its opaque `XRC1:` broadcast frame, current lifecycle status and optional
revocation timestamp. The historical record remains readable after expiry or
revocation so an operator can recover the exact signed artifact; the separate
WAV route remains limited to active, unexpired Codes. This detail projection
contains no API-key secret, Audience HMAC credential, raw Audience event or
measurement token.

## Organisation members

An owner or administrator with an MFA-authenticated OIDC session can correct
display names without replacing stable tenant references:
`PATCH /v1/organizations/{organization_id}` and
`PATCH /v1/projects/{project_id}?organization_id=...` accept the closed body
`{"name":"..."}`. Core preserves organization/project IDs and therefore all
memberships, Codes, campaigns, API keys, subscriptions and Audience references.
The actions are recorded as `organization.updated` and `project.updated` with
the new non-secret name. Project names remain unique within one organisation;
a collision returns `409 project_name_conflict`.

An owner with an MFA-authenticated session can permanently close a tenant with
`DELETE /v1/organizations/{organization_id}` and a JSON `confirmation` equal
to the complete organization identifier. Core first proves owner authority and
refuses closure while a recurring Stripe subscription or billing checkout is
active. It then purges the tenant through the private Core-to-Audience control
channel and rechecks the closure gates transactionally before deleting the Core
organization. Core cascades projects, Codes, campaigns, API keys, memberships,
usage, invitations and local support/billing rows. Audience transactionally
deletes verified events, replay nonces, integrity aggregates, integration
secrets and isolated legacy aggregates. A temporary Audience outage therefore
fails closed without deleting Core data; a retry is safe if Audience was
already purged.
Before the private purge, Core persists `deletion_requested_at` on the
organization and takes a row lock shared with every new Audience lifecycle
intent. New provisioning, revocation, retention, HMAC rotation and Core-key
update operations then return `409 resource_deletion_in_progress`. If an
operation was already requested, closure returns
`409 deletion_operations_pending` without purging until that exact audit is
`completed` or `failed`; it also serializes against an already-intented
project deletion. Retrying resumes the same irreversible closure.

Before closure, the owner can download
`GET /v1/organizations/{organization_id}/export`. The attachment is generated
from one repeatable-read Core snapshot and contains bounded organization,
member, project, campaign, signed XR Code and non-secret API-key metadata plus
bounded Audience integration metadata. It excludes every API/HMAC/provider
credential, raw Audience event, measurement token, invitation token and
support message body. Oversized tenants fail with
`422 organization_export_too_large` and must use an asynchronous support
export; the endpoint never silently truncates an archive.

An owner or administrator with MFA can similarly delete exactly one project
with `DELETE /v1/projects/{project_id}?organization_id=...` and a JSON
`confirmation` equal to the complete project identifier. Core first verifies
that the project belongs to the selected tenant, Audience purges only rows
bound to both organization and project, then Core reauthorizes and deletes the
project. Cascades remove that project's campaigns, Codes and project API keys;
other projects and their Audience data remain intact.
The project receives the same durable, project-scoped deletion intent before
purge. Core refuses new distributed Audience mutations and waits for requested
audits carrying that exact `project_id` to become terminal. A failed private
purge leaves the marker in place so the administrator can retry without
reopening a creation race.

The public team workflow uses one-time invitations instead of requiring a
customer to discover a Keycloak subject identifier:

- `POST /v1/member-invitations` accepts `organization_id`, a verified-account
  `email`, `role` (`member`, `billing` or `admin`) and an expiry from 1 to 14
  days. It returns `secret` once and queues the same token for transactional
  e-mail delivery when SMTP is configured. The database stores a keyed HMAC-SHA-256 of
  the normalized address and a SHA-256 hash of the high-entropy token; neither
  appears in lists or audit data. The e-mail-derived HMAC is replaced with
  non-address material after acceptance, revocation, or observed expiry.
- `GET /v1/member-invitations?organization_id=...&limit=50&cursor=...` lists
  bounded metadata, lifecycle status and the non-sensitive `delivery_status`
  (`pending`, `processing`, `sent`, `cancelled`, `dead` or `unavailable`) only. An owner or administrator may
  revoke a pending token through
  `POST /v1/member-invitations/{invitation_id}/revoke`. The Console follows
  `next_cursor` to expose older invitations without replacing or mixing the
  current organisation's visible history.
- `POST /v1/member-invitations/accept` binds the token to the authenticated
  account's verified e-mail. Consumption is row-locked and atomic; expired,
  revoked, already-used or replayed tokens are refused. Administrator
  membership requires an MFA-authenticated session. Invitations can never
  create an owner. After terminal redaction, a replay returns the same generic
  `invitation_invalid` response as an unknown token and does not reveal that a
  valid invitation previously existed.

The delivery outbox encrypts the normalized destination and token with an
independent HKDF-derived AES-256-GCM key. It retries with bounded exponential
backoff and destroys the ciphertext after delivery, cancellation, expiry or
five failed attempts. Until SMTP is configured, the creator can still transmit
the displayed token once through a trusted out-of-band channel. The console
keeps it only in memory and clears it on request or page exit.

`GET /v1/members?organization_id=...&limit=50&cursor=...` lists a newest-first,
bounded `{data,next_cursor}` team page only to organisation owners and
administrators. Same-timestamp rows continue deterministically by subject ID.
`PUT /v1/members/{subject_id}?organization_id=...`
accepts a role of `owner`, `admin`, `member` or `billing`; `DELETE` on the same
path removes a member. These routes require an OIDC user token, never a server
API key. Only an owner can create, change or remove an owner or administrator;
an organisation cannot lose its last owner. Every change is recorded in the
tenant audit log.

`GET /v1/audit-log?organization_id=...&limit=50&cursor=...` returns a stable
keyset page of at most 100 tenant-scoped audit entries to organisation owners
and administrators. `next_cursor` is null at the end; otherwise pass it
unchanged to retrieve older entries. Cursors are bounded and opaque, and the
query uses the tenant/id index without skipping entries appended concurrently.
Entries contain action metadata only; API-key secrets and Audience HMAC
secrets are never recorded there.

## Codes

Core's in-memory compatibility path and PostgreSQL production path share one
strictly compiled TypeScript XR-contract module for recursive key-sorted JSON
canonicalisation, `XRC1` broadcast-frame construction, and typed Intent and
Audience-policy normalisation. The same module defines the exact XR Code
signing projection, closed issuance response and schema-bound five-minute
Audience proof payload. It also builds the exact two-minute receiver resolution
payload covered by Core's Ed25519 signature and verified by the Flutter SDK.
Intent parameters become a detached JSON value; cycles, non-finite numbers and
values that JSON cannot represent unambiguously are rejected. Contract tests
pin output to the existing XR v1 signature bytes, public error semantics and
published JSON Schema patterns.

- `POST /v1/codes` accepts `organization_id`, `project_id`, optional
  `campaign_id`, `issuer`, future `expires_at`, and an `intent` containing
  `type`, `action` and `resource`.
- `POST /v1/codes/batch` accepts a count from 1 to 100 plus the same template.
- `POST /v1/codes/recover` requires the exact original single-Code body and
  `Idempotency-Key`. It only returns the retained 24-hour receipt: it never
  issues a Code, consumes quota or changes usage. An unknown/expired receipt
  returns `code_issue_not_found`; a changed body returns `idempotency_conflict`.
- Both issuance routes accept an optional `Idempotency-Key` header containing
  16 to 128 URL-safe printable characters. A matching retry during the next
  24 hours returns the exact original single or batch response without issuing
  another Code, incrementing usage or consuming quota again. Reusing a key for
  a different payload or for the other issuance route returns
  `409 idempotency_conflict`. Core stores only SHA-256 hashes of the key and
  canonical request plus the bounded response, isolated by organisation.
- `POST /v1/codes/batch/recover` requires the original batch body and
  `Idempotency-Key`. It returns the retained response with `200` but never
  issues Codes, mutates quota or creates an idempotency receipt. An unknown or
  expired receipt returns `404 code_issue_not_found`; a changed body returns
  `409 idempotency_conflict`. The console keeps only this non-secret recovery
  tuple in browser session storage for at most 23 hours.
- `GET /v1/codes` and `POST /v1/codes/{xr_id}/revoke` require an organisation
  context in `X-Xaere-Organization-Id` (or `organization_id` query parameter).
  Members may list; owners and admins may issue or revoke.
- Code listings expose the effective lifecycle status: `active`, `expired`, or
  `revoked`. Expiry is derived from `expires_at`; it does not mutate the signed
  Code or replace explicit revocation.
- Code listings also expose the non-secret `audience_enabled` flag and the
  matching `audience_integration_id` (or `null`) so an authorised backend or
  Console can select only Codes attributable to a report integration.
- `GET /v1/codes` is keyset-paginated. `limit` defaults to 50 and is bounded
  from 1 to 100. Pass the opaque `next_cursor` from one response as `cursor`
  to obtain the next stable page; a null cursor means the listing is complete.
  Cursors are navigation state only and never bypass organisation, project or
  `codes:read` authorization.

An issued Code includes `broadcast_token` and `broadcast_frame`, where the
frame is `XRC1:brd_...`. Xaere renders protocols 3, 4 and 5 only, with protocol
4 (ultrasound fast) as the default. A ggwave transmitter sends this frame; the Flutter
receiver normalises it and resolves only the opaque token through Core.

`GET /v1/codes/{xr_id}/audio?organization_id=...` renders a downloadable WAV
for an active, unexpired Code. It is authorised with `codes:read` and is built
only by the private Xaere Audio service from the stored opaque frame. The
renderer has no database access, no public port and accepts neither arbitrary
messages nor shell arguments.

`POST /v1/codes/audio-bundle?organization_id=...&project_id=...` accepts only
`{"xr_ids":[...]}` for 1 to 100 explicitly selected Codes and returns one
stored ZIP containing their WAV files. Core rechecks `codes:read`, tenant,
project and active/unexpired state for the complete selection before returning
anything. Rendering uses bounded concurrency and the uncompressed WAV payload
is capped at 64 MiB; the archive contains no intent, signature or Audience
configuration.

An optional `audience` object must contain an enabled boolean and, when true,
an Audience integration identifier owned by the same organisation and project.
Core verifies this over its private Audience control channel before issuing the
Code. Resolution then creates a five-minute signed proof tied to that XR Code
and integration. Audience and the Flutter receiver both reject a proof whose
signed lifetime exceeds five minutes or whose issue time is more than two
minutes in the future; expiry alone is not accepted as sufficient validation.

## Server API keys

`POST /v1/api-keys` creates an organisation API key bound to exactly one
project, with explicit scopes: `codes:read`, `codes:write`,
`audience:read` and `audience:write`. `expires_in_days` is optional, defaults
to 90 and must be between 1 and 365. PostgreSQL rejects an expired credential
before updating `last_used_at`, even when it was never manually revoked.
The returned `xak_...` secret is shown once and is stored only as a SHA-256
digest. A scoped key may create and list Codes only in its own organisation and
project. It cannot create organisations, projects or other keys. Owners and
admins use `GET /v1/api-keys?organization_id=...&project_id=...&limit=50&cursor=...`
to list at most 100 newest-first key records for one selected project without
their secrets. Omitting `project_id` retains the organisation-wide
administrative inventory. The opaque exact
timestamp/id cursor retrieves older rotation history explicitly. Each record
includes `expires_at` and the effective `active`, `expired` or `revoked`
status; one key is revoked at
`POST /v1/api-keys/{api_key_id}/revoke`.
`POST /v1/api-keys/{api_key_id}/secret?organization_id=...` lets an OIDC
owner or administrator atomically replace an active, unexpired key's secret
without changing its identifier, project, scopes or expiry. The previous
secret is rejected immediately and the replacement is returned only once.
Lists expose only `secret_rotated_at`; the `api_key.secret_rotated` audit entry
contains no credential or digest.

The reference Node.js publisher client is documented in
`PUBLISHER_BACKEND.md`. It keeps the `xak_` credential server-side, fixes the
organisation and project context at construction, supports single/batch
issuance, listing, revocation and bounded RIFF/WAVE retrieval, and refuses
redirects. Give that key only the `codes:read` / `codes:write` scopes actually
needed by the CMS or broadcast scheduler.

`GET /v1/usage` returns the current monthly number of issued Codes, the active
Codes plan quota, and an organization-scoped XR Audience usage summary grouped
only by entitlement mode (`demo`, `contract`, `self_service`). Audience computes
the count in its separately credentialed database through the private Core
control channel. The response contains event counts, active integration count
and monthly quota only—never event rows, XR IDs, measurement tokens, countries
or proofs. When Audience is temporarily unavailable, Codes usage remains
readable and the Audience projection is explicitly marked `unavailable`.
Core enforces `monthly_code_quota` before issuing a Code;
reaching it produces `quota_exceeded` with HTTP 429. Every new organisation
receives the automatic `Xaere Codes Starter` plan with 100 Codes per calendar
month. Codes entitlement selection is product-scoped, so a newer Audience
subscription can never remove this quota or replace a paid Codes plan.

Single and batch issuance serialize their quota check per organisation. The
signed Code insert and daily usage increment commit atomically, so concurrent
requests cannot exceed the selected monthly plan quota.
After any lock wait, Core rechecks campaign scheduling and the requested Code
expiry before inserting anything.

## Billing catalogue and checkout

`GET /v1/billing/offers?country=TN` lists only active offers for enabled
providers. It contains neither credentials nor organisation data.

`GET /v1/billing/subscriptions?organization_id=org_…` returns the caller’s
organisation plan names, provider, entitlement status and period end. It never
returns payment-method, provider-customer or provider-subscription references.
Each subscription includes a closed `accounting` projection describing whether
its Dolibarr draft is `not_scheduled`, `pending`, `processing`, `completed` or
`failed`. A completed projection may include the bounded Dolibarr invoice ID;
the accounting payload and worker error text are never exposed.

`POST /v1/billing/checkouts?organization_id=org_…` requires an OIDC user with
an organisation billing role and accepts `{ "offer_id": "offer_…" }`. It
returns a hosted Stripe or Flouci checkout URL. A browser return URL is never
payment confirmation: signed provider webhooks reconcile an internal checkout
before an active subscription is created.

`GET /v1/billing/checkouts/bco_…?organization_id=org_…` lets the same billing
roles inspect the bounded server status (`pending`, `completed`, `failed` or
`expired`) after a provider return. It exposes no provider reference, customer
identifier or payment data. A provider-page creation failure closes the
internal checkout as `failed`; an abandoned `pending` checkout expires after
24 hours and no longer blocks organisation closure. A `success` browser query
still grants nothing until the signed webhook has produced `completed` state.

`POST /v1/billing/portal?organization_id=org_…` creates a short-lived Stripe
Customer Portal URL for an organisation billing user. It is available only
after a verified Stripe checkout has supplied a `cus_…` customer reference;
the browser receives the hosted URL only, never a Stripe customer ID or secret.

Verified Stripe lifecycle webhooks reconcile subscriptions server-side. Core
refuses an unpaid Checkout Session, verifies the `sub_…` subscription against
Stripe and the internal checkout metadata, and supports both legacy and Basil
item-level billing periods. A paid invoice reactivates the entitlement, a
failed invoice becomes `past_due`, and a deleted or expired subscription loses
entitlement. Core ignores unknown Stripe states and older provider events, so a
browser redirect cannot affect access.

Webhook receipts stay pending until their reconciliation succeeds. If Core has
a transient failure, a verified provider retry reuses the stored event id and
completes it idempotently; only then is `processed_at` recorded.
For Flouci, a payment id whose receipt is already processed is acknowledged
without another provider request. Missing and pending receipts are still
verified server-to-server before reconciliation, and those outbound
verifications are protected by a dedicated Redis-backed public rate limit.

The durable organization-deletion intent is also a billing fence. Core refuses
new hosted checkouts and new active Audience contracts after closure starts.
Provider completion and reconciliation take the same database boundary: a
delayed Stripe or Flouci event cannot restore `trialing`, `active` or
`past_due`, while terminal `cancelled` and `expired` events remain admissible.
Any entitlement projection for a closing organization is normalized to
`cancelled` before it reaches the isolated Audience service.

Platform administrators with Keycloak realm role `xaere_platform_admin` manage
the non-secret catalogue through `GET /v1/billing/admin/catalog`,
`PUT /v1/billing/admin/providers/{provider}`, `PUT /v1/billing/admin/plans/plan_…`
and `PUT /v1/billing/admin/offers/offer_…`. Flouci offers are constrained to
`TN` / `TND`, a positive `amount_minor` in millimes and an explicit
`entitlement_months` duration from 1 to 36. A verified one-time Flouci payment
therefore never creates an unlimited entitlement. Stripe requires a `price_…`
reference and derives the period from the verified provider subscription.

The administration catalogue is a closed projection. Provider activation,
plans, payment offers, contractual Audience entitlements and Dolibarr customer
mappings are listed in the platform billing console. PostgreSQL `bigint`
quotas and amounts are returned as JSON integers, and no provider credential,
checkout payload, customer reference or invoice payload is included.

Contractual XR Audience activation is managed with
`PUT /v1/billing/admin/audience-contracts/{organization_id}`. The request
contains an active Audience `plan_id`, a unique operator contract reference,
an `active` or `cancelled` status, and an optional future period end. The
entitlement is tenant-bound, audited, subject to the selected plan quota and
ignored after its period end. Core copies the immutable entitlement end into
the separately credentialed Audience database when an integration is created;
Audience independently refuses ingestion after that instant. It enables the
`contract` integration mode; it does not create a payment event or bypass Core
entitlement checks. An organisation has at most one active manual Audience
contract: activating a new reference atomically cancels an older one before
Core synchronises the organisation's existing contractual integrations.

The same platform role can inspect credential status with
`GET /v1/platform/settings` and rotate one allowed credential with
`PUT /v1/platform/settings/secrets/{name}`. Reads return only `configured` and
`updated_at`; plaintext values are never returned. Core encrypts each value
with AES-256-GCM before PostgreSQL storage and uses the new provider credential
without a restart. The root-only `XAERE_PLATFORM_SECRETS_KEY_B64` master key is
not managed by this API.

The same administrator can update non-secret connection values with
`PUT /v1/platform/settings/configuration/{name}`. Allowed names are
`proxi_support_base_url`, `proxi_support_mailbox_id`, `dolibarr_base_url`,
`dolibarr_entity_id`, `dolibarr_tva_rate`,
`dolibarr_enable_invoice_sync`, `smtp_host`, `smtp_port`, `smtp_secure`,
`smtp_from_email` and `smtp_from_name`. `GET /v1/platform/settings` returns
their effective database override or root-environment/default fallback so the
operator console survives a reload. SMTP username and password remain in the
redacted secret-status projection and are never returned. URLs require HTTPS
and cannot contain credentials, a query or a fragment.

`GET /v1/platform/release-readiness` gives the MFA-authenticated platform
administrator one fresh, redacted SDK launch summary. It reports only the
immutable preview version/revision and boolean availability, TestFlight upload, Android/iOS
physical-acoustic and signed stable-channel gates, plus the verified stable
version/revision when one exists. A root timer recomputes this status every five
minutes from immutable manifests and mode-`0600` evidence. Device details,
evidence contents, private keys and credentials are never mounted into Core or
returned by the API; a stale, malformed or asymmetric status fails with `503`.

`PUT /v1/billing/admin/dolibarr/customers/{organization_id}` associates an
organisation with its pre-existing Dolibarr third-party identifier. This is a
platform-administrator operation. When the optional Dolibarr worker is enabled,
it creates only a draft invoice and one line using the idempotent external
reference `xaere:{outbox_id}`; it never validates an invoice or sets it paid.
The same mapping can be created and reviewed in the platform billing section of
`console.xaere.io`. `GET /v1/billing/admin/catalog` returns only the organisation
identifier and name, the numeric Dolibarr third-party identifier and the update
timestamp; it never returns the Dolibarr API key or an invoice payload.

## Support

`POST /v1/support/tickets?organization_id=...` lets a verified OIDC user open
a Proxi ticket through Xaere Core. The browser never receives the Proxi API
key. `request_id` is an opaque `supreq_...` idempotency key scoped to the user
and organisation, so retrying the same submission does not create a second
remote conversation.

`GET /v1/support/tickets?organization_id=...&limit=50&cursor=...` returns a
newest-first page with at most 100 tickets and an opaque `next_cursor`. The
cursor is a stable `(created_at, ticket_id)` keyset boundary; callers follow it
explicitly to retrieve older tickets. It is validated and tenant-scoped, and
must not be constructed or reused as a ticket identifier.

`GET /v1/support/tickets/{ticket_id}?organization_id=...` reads one local
tenant-scoped ticket and its customer-visible Proxi conversation. Core verifies
the immutable Proxi conversation and external ticket references before
returning a closed message projection. Internal notes, attachment URLs,
provider customer records and webhook payloads are never exposed to the
browser.

## Audience

Core is the only public control plane for XR Audience. Its private calls to the
Audience container are authenticated with the internal service credential,
aborted after a bounded deadline and read through an 8 MiB JSON ceiling. A
timeout, malformed document, non-object JSON or oversized report becomes one
stable `503 audience_unavailable` response; private response bodies and
transport errors are never forwarded to the public caller. Operators may tune
the timeout from 1 to 60 seconds and the ceiling from 1 KiB to 16 MiB through
the root-owned runtime configuration.

Audience control is exposed through Core, never through the Audience
administrator endpoint. `POST /v1/audience/integrations` accepts either an
organisation owner/administrator OIDC session or an `xak_` backend key with
`audience:write`, plus its `organization_id`, `project_id`, mode and retention
period. Core checks organisation/project scope then calls Audience over its
private Docker network. The HMAC secret is generated by Audience, encrypted at
rest with the service-held AES-256-GCM key, and returned exactly once. The
console can reveal or download that one-time value only immediately after
creation or rotation; it does not put it in web storage and list operations never return it.
Core writes one tenant-scoped `audience.integration_provisioning` audit intent
before the private call, then marks it completed with only the new integration
identifier or failed without storing the HMAC secret, hash or ciphertext. A
temporary audit-finalization failure never discards the one-time credential
already returned by Audience.

`GET /v1/audience/integrations?organization_id=...&project_id=...&limit=50&cursor=...`
requires `audience:read` (or an authorised user) and returns at most 100
newest-first records. Its exact timestamp/id cursor crosses the authenticated
private Core-to-Audience channel and retrieves older rotation/revocation
history explicitly; neither page contains an HMAC secret or hash. Revocation uses
`POST /v1/audience/integrations/{integration_id}/revoke` with the same
organisation and project context and `audience:write`. Lists never reveal the
secret. `POST /v1/audience/integrations/{integration_id}/secret` with the same
tenant context and `audience:write` atomically replaces an active integration's
HMAC credential. The old secret is rejected immediately, the new secret is
returned exactly once, and only `secret_rotated_at` remains visible in later
list responses. Core records an `audience.integration_secret_rotation` tenant audit entry with the integration,
project, requested/completed state and rotation timestamp, but never the HMAC
credential, hash or ciphertext. An integration has a `demo`, `contract` or `self_service` mode and a
retention period from 1 to 730 days. Demo integrations are limited to 14 days
of retention and 100 events per month. Contractual and self-service modes
require an active Audience plan; its monthly event quota is enforced by the
Audience ingestion transaction.

Production seeds an internal `XR Audience Contract 5K` plan in TND with a
5,000-event monthly quota. It has no payment offer and is never attached to an
organisation automatically: a platform administrator must associate it with a
signed contract. Self-service remains unavailable until an explicit Stripe or
Flouci offer is configured for an Audience plan.

Revocation records a separate `audience.integration_revocation` audit intent
with the exact tenant, project and integration boundary. It becomes completed
only after Audience atomically disables ingestion, or failed when the private
operation is refused; historical aggregate reports remain available and no
event or credential material enters the Core audit.
All Audience lifecycle audit finalizers are monotonic: only a `requested`
intent of the exact expected action and tenant can become `completed` or
`failed`. A duplicate, delayed or mismatched finalizer cannot rewrite an
already terminal audit record.

`PATCH /v1/audience/integrations/{integration_id}/retention` with the same
organisation/project context and `audience:write` changes `retention_days`
without changing the integration ID, mode or HMAC credential. Core revalidates
the current demo/contract/self-service entitlement before the private Audience
request. Audience locks the active integration and updates it transactionally;
events and integrity-day aggregates outside a reduced window are deleted in
that same transaction and cannot reappear in reports. Increasing a window does
not restore data already deleted. The response reports previous/current days
and exact deletion counts. Core retains an
`audience.integration_retention_update` requested/completed/failed audit entry
containing policy metadata and counts only, never events, tokens or secrets.

When rotating `CORE_SIGNING_PRIVATE_KEY_B64` / `CORE_SIGNING_KEY_ID`, first
restart Core in a controlled maintenance window with the replacement root-only
key. A trusted publisher backend then calls
`POST /v1/audience/integrations/{integration_id}/core-key` with an `xak_` key
holding `audience:write` and its `organization_id` / `project_id`. Core sends
its currently published replacement key to Audience over the private control
network; Audience atomically replaces only that scoped integration's trusted
key and returns the new key identifier, never the HMAC secret. Validate a
fresh resolve and ingestion for each active integration before ending the
maintenance window. Do not retire the previous key until every integration has
been updated and verified.
Core records an `audience.integration_core_key_update` intent before the
private operation and finalizes it monotonically as `completed` or `failed`.
The tenant audit contains the project, integration and public key identifiers
only; neither the PEM nor any private/HMAC credential is stored in audit data.

`GET /v1/audience/reports/{integration_id}?organization_id=...&project_id=...`
accepts optional ISO-8601 `from` and `to` timestamps to select an
inclusive/exclusive period (`occurred_at >= from` and `occurred_at < to`).
An optional `campaign_id=cmp_...` narrows event, reach, frequency and all event
dimensions to that campaign. Core first verifies that the campaign belongs to
the requested organisation and project; a foreign or unknown campaign returns
`404` without querying Audience aggregates. Archived campaigns remain
reportable because historical attribution is immutable.
An optional `xr_id=xr_...` narrows the same aggregates to one Code. Core first
verifies that the immutable Code belongs to the requested organisation and
project and was issued for the selected Audience integration; a foreign,
non-Audience or differently integrated Code returns `404` without querying
Audience. Campaign and XR Code filters may be combined as an intersection.
Revoked integrations also remain reportable, within their retained period,
through the same exact organisation/project scope. Revocation blocks new
ingestion and credential rotation immediately; it does not erase or hide
already verified aggregates. Project or organisation deletion still purges
the corresponding Audience records.
`from` must precede `to`. Console date inputs use UTC day boundaries and
include the selected end day. Every returned dimension is capped at 10,000
entries. If a dimension would
exceed that published bound, the API returns `422 report_too_large`; split the
request into narrower periods rather than accepting a truncated aggregate.
Scope validation, totals and every report dimension are read inside one
PostgreSQL `REPEATABLE READ`, read-only snapshot, so concurrent ingestion
cannot produce an internally inconsistent aggregate export.
returns only the scoped aggregate report through Core’s private control channel.
It never exposes the Audience administrator token, event payloads or HMAC
secrets. Reports include event counts, `estimated_reach` (unique rotating
measurement tokens within the integration), `average_frequency`, a frequency
distribution, daily aggregates and receiver-class totals. These are
privacy-preserving estimates: no token, account, device or raw event is ever
returned, and rotating tokens must not be treated as persistent person IDs.

Reports also return `integrity_signals`: UTC-day aggregate counters for exact
event replays, code/token duplicates and Core-proof replays observed only after
the integration HMAC and Core proof have both been verified. They are quality
and anti-replay indicators, not proof that a person or publisher committed
fraud. At most one counter row is stored per integration/day; no raw request,
IP address, measurement token, proof or device identifier is retained. These
counters follow the integration retention period and the report's overlapping
UTC-day range. Integrity rows have neither campaign nor XR Code identifiers,
so they deliberately remain integration-wide for the selected period when
either filter is present;
the API, console and exports state this scope explicitly.

The console can prepare `xaere-audience-aggregate-v1` JSON and CSV exports from
this response. An XR Code filter labels the same closed projection
`xaere-code-performance-v1`; a campaign filter uses
`xaere-campaign-performance-v1`. The exporter uses a closed, reconciled
projection: every report
and dimension key must match the reviewed aggregate contract, all dimension
totals must reconcile, and any missing or new field causes export to fail until
it is reviewed. Export files include tenant/project/integration scope, period,
summary, campaign/XR Code/country/receiver/day/frequency aggregates and qualified
integrity counters. Exports also record `filters.campaign_id`, `filters.xr_id`
and
`filters.integrity_scope=integration_period` so a filtered report cannot be
mistaken for filter-scoped integrity data. They never include raw events, rotating measurement tokens,
resolution proofs, IP addresses or device identifiers. Prepared exports live
only in page memory and are cleared on context change, sign-out and page exit.

When a campaign filter is selected, the console also requests
`GET /v1/campaigns/{campaign_id}/summary` through the same authenticated Core
session. It binds the returned organisation, project and campaign identifiers
to the selected workspace, then reconciles `issued = active + expired +
revoked` and verifies that Audience-enabled Codes never exceed issued Codes.
The resulting export is `xaere-campaign-performance-v1`: it contains the same
privacy-preserving Audience aggregates plus campaign metadata and operational
Code lifecycle counts. A missing, foreign, structurally new or inconsistent
campaign summary makes export preparation fail closed. Code payloads,
signatures, frames, intents, secrets and raw Audience data remain excluded.

Historical imports are exposed separately through
`GET /v1/audience/legacy-report?organization_id=...&project_id=...` with the
same `audience:read` authorisation and optional `from` / `to` timestamps. The
response is always labelled `legacy_unverified_aggregate`, declares
`reach_comparable_to_xr_v1: false`, and contains only minimised daily
aggregates. It never returns `estimated_reach` and never reads or writes the
verified XR v1 event table. Import and rollback are offline owner-only
operations documented in `AUDIENCE_CUTOVER.md`.

When a Code is associated with a campaign, Core includes its `campaign_id` in
the signed resolution proof. Audience accepts that attribution only after
verifying the proof and exposes the aggregate in `by_campaign`; the event
payload cannot supply or override a campaign.

`POST /v1/events` accepts only a minimised event: event ID, XR ID, occurrence
time, receiver class, ephemeral measurement token, integration ID, optional
country code and Core resolution proof. The raw JSON body has an HMAC-SHA256
signature owned by the publisher backend. Audience verifies both this HMAC and
the Core Ed25519 proof, then records an idempotent event in its separate
database. Persistent device IDs, accounts, raw IP addresses and precise
location are rejected by the event contract.

The receiver generates `event_id` once per consented measurement, independently
of the device and account, and reuses it unchanged across encrypted delivery
retries. A publisher backend must forward this value unchanged. Consequently,
a successful ingestion whose HTTP response was lost is classified as the exact
same event replay rather than a new event or a code/token duplicate.

The reference Flutter publisher backend also requires a short-lived reporting
JWT with `iat`, `exp`, `xaere_audience_consent: true` and an
`xaere_audience_integration_id` equal to its configured integration. Its
default maximum lifetime is five minutes. A valid application session without
these consent and integration claims is refused before the HMAC-protected
Audience request is opened. Production OIDC/JWKS integrations must enforce the
same properties even when they use a different claim vocabulary.
The Flutter SDK's built-in reporter also invalidates deferred authorization and
aborts active HTTP delivery before a consent-withdrawal call returns. A custom
reporter should implement `XaereCancelableMeasurementReporter` to preserve the
same cancellation boundary.

`occurred_at` must fall inside the signed Core proof window (with at most two
minutes of clock skew) and cannot be arbitrarily postdated. The signed window
itself cannot exceed five minutes. Proofs must match the exact published v1
shape; additional proof fields are rejected.
Every new proof contains a signed opaque `proof_id`, so simultaneous
resolutions of the same Code remain distinct while exact proof replays are
deduplicated. During the five-minute proof transition window, receivers still
accept the immediately preceding v1 shape without `proof_id`.

`GET /v1/reports/{integration_id}` is administration-only. A Flutter client
must send a report to its own trusted backend; it must never contain an
Audience HMAC secret.
When an integration’s Core verification key is rotated, Audience retains only
the immediately preceding public key for seven minutes. This covers a
five-minute proof already in flight plus the two-minute clock-skew allowance;
the old key is rejected automatically after that bound.
