openapi: 3.1.0

info:
  title: ID Side Public API
  version: "1.0.0"
  summary: AI consent infrastructure — resolve consent, force provider parameters, issue verifiable proof.
  description: |
    The public HTTP contract of ID Side. Everything a customer's backend, the
    consent widget, an auditor or a regulator can reach is documented here, and
    nothing else is supported.

    ## The one-paragraph version

    Before an application calls an AI provider, its backend calls
    `POST /v1/authorize`. ID Side resolves what the end user actually agreed to,
    answers with the provider parameters that must be applied to that call, and
    issues a signed **ACT token** — a cryptographic proof, bound to that one
    request, that the call was authorized and how. The proof is appended to an
    immutable hash-chained ledger in the same transaction, before the answer is
    returned. Anyone can check the proof afterwards through
    `GET /v1/verify/{token_id}`, with no account and no permission from us.

    ## Four properties this API is built around

    **Fail-closed.** If this API does not answer, no AI call may go out. The
    official SDKs enforce this: an unreachable or erroring `/v1/authorize`
    raises rather than falling through to the provider. A `402`, `403`, `429` or
    `503` here is a refusal, not a hint — there is no "allow anyway" path, because
    a consent system with a bypass is a consent system with no evidentiary value.
    The only documented exception is a short-TTL cache of a *signed proof*,
    never a cache of a decision we did not sign.

    **The forced parameters are not advisory.** `params_forced` in an authorize
    response (for example `{"store": false}`) is the enforcement half of the
    product. The SDK merges it into the outbound provider request and refuses to
    let the caller override it. If you integrate by hand, applying these
    parameters is what makes the token you attach true; a proof that says
    `training: false` next to a request that was in fact retained for training
    is worse than no proof at all — it is a documented misrepresentation.

    **One token, one request.** Each ACT token carries the `request_hash` of the
    exact request it authorizes, a single-use `nonce`, and a one-hour TTL. It is
    not a session, not a bearer credential and not reusable across calls: one
    `POST /v1/authorize` per AI request, every time. Re-attaching a token to a
    second request produces a proof whose `request_hash` does not match what was
    sent, which any verifier can detect.

    **The tunnel is opaque to the developer.** There is deliberately no
    secret-key endpoint that writes or overrides an end user's preferences, and
    there never will be. Preferences enter the system only from the person
    themselves, through the widget (`POST /v1/consent`, which additionally
    requires a single-use handshake token minted by us when the widget actually
    rendered), from their ID Side account, or from an organisational policy. A
    developer can read the resolved outcome; they cannot author it.

    ## Identifiers and what we store

    Your own `user_id` never reaches our database, our logs or the ledger. It is
    pseudonymised on arrival with a per-app key (`psu_…`), so the same person is
    a different pseudonym in every app and no cross-tenant correlation is
    possible. `POST /v1/authorize` receives **no prompt, no messages and no
    completion** — only a hash of the request. ID Side is structurally incapable
    of reading what your users ask an AI.

    ## Errors

    Every error is an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem
    document served as `application/problem+json`, with a stable machine-readable
    `type` URL. Match on `type`, never on `detail` — the prose is written for a
    human and may be improved at any time. The complete catalogue of `type`
    values is the enum on the `Problem` schema below; each operation lists the
    ones it can emit.

    ## Stability

    The path prefix `/v1` is the contract. New optional response fields and new
    error `type` values may be added within `v1`; existing fields will not change
    meaning or disappear, and no currently accepted request shape will stop being
    accepted. Ignore fields you do not recognise, and treat an unknown error
    `type` as a refusal at the HTTP status class it arrives with.
  contact:
    name: ID Side support
    email: support@idside.eu
    url: https://idside.eu/developers
  license:
    name: Proprietary — ID Side
    url: https://idside.eu/terms
  termsOfService: https://idside.eu/terms

servers:
  - url: https://api.idside.eu
    description: Production.

tags:
  - name: Authorization
    description: The critical path. One call per AI request, before the provider is contacted.
  - name: Verification
    description: Checking a proof, and the public keys needed to check it offline.
  - name: Consent
    description: |
      The widget's endpoints — the only writers of consent in the entire system.
      Publishable key plus a declared `Origin`, plus a single-use handshake token.
  - name: Account linking
    description: Linking an app user to their central ID Side account, through a first-party popup.
  - name: Ledger
    description: The customer's own evidence, and the means to check it.

paths:
  /v1/authorize:
    post:
      operationId: authorize
      tags: [Authorization]
      summary: Authorize one AI call and issue an ACT token
      description: |
        Called by your backend, through the SDK, immediately before every AI
        provider call. It resolves the end user's consent, returns the provider
        parameters you must force on that call, issues a signed ACT token bound
        to that one request, and appends the ledger entry — synchronously, inside
        the same transaction as the token record, so the proof exists before the
        call goes out.

        **Send no prompt content.** The only description of the request that
        reaches us is `request_hash`.

        **Naming the user.** Three accepted shapes, in this order of precedence:
        `user_ref` (explicit), then `ubt`, then `user_id` (flat, what the SDKs
        send today). They differ in evidentiary weight, and the difference is
        recorded in the token as `binding`:

        * `ubt` — the widget saw this user and ID Side signed the binding;
        * `user_id` — the developer simply asserts it. Accepted (no widget means
          no UBT), and visible in the proof as the weaker link it is. An app can
          be configured to require a UBT, in which case a flat `user_id` is
          refused with `ubt_required`.

        **On refusal.** A `402` (quota) or `403` (fraud review) means no token was
        issued and no AI call may proceed. Do not retry these; they will not
        clear on their own.
      security:
        - secretKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AuthorizeRequest"
            examples:
              flat_user_id:
                summary: What the SDKs send today
                value:
                  user_id: usr_marie_456
                  provider: openai
                  model: gpt-4o
                  request_hash: "sha256:9f2c1ab4d3e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f"
              user_binding_token:
                summary: After the widget has bound the user
                value:
                  ubt: eyJraWQiOiJ1YnQtMjAyNnEzIiwidHlwIjoidWJ0K2p3dCIsImFsZyI6IkVTMjU2In0.eyJzdWIiOiJwc3VfZXhhbXBsZSJ9.signature  # pragma: allowlist secret
                  provider: anthropic
                  model: claude-sonnet-5
                  request_hash: "sha256:0011223344556677889900aabbccddeeff00112233445566778899aabbccddee"
              explicit_user_ref:
                summary: The explicit form
                value:
                  user_ref:
                    type: user_id
                    value: usr_marie_456
                  provider: openai
                  model: gpt-4o
                  request_hash: "sha256:9f2c1ab4d3e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f"
      responses:
        "200":
          description: |
            Authorized. Apply every parameter in `params_forced` to the provider
            request, attach `act_token`, and make the call.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthorizeResponse"
        "401":
          description: |
            The key is not a valid secret key (`invalid_api_key` — a publishable
            key presented here fails the same way), or the identity could not be
            established: `ubt_invalid` (bad signature, expired, or issued for a
            different app), `ubt_required` (this app refuses flat `user_id`).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
              examples:
                invalid_api_key:
                  value:
                    type: https://idside.eu/errors/invalid_api_key
                    title: Invalid API key
                    status: 401
                    detail: Provide a valid secret key (sk_...) in the Authorization header.
                ubt_required:
                  value:
                    type: https://idside.eu/errors/ubt_required
                    title: Ubt required
                    status: 401
                    detail: This app requires a User Binding Token.
                ubt_invalid:
                  value:
                    type: https://idside.eu/errors/ubt_invalid
                    title: Ubt invalid
                    status: 401
                    detail: UBT was issued for a different app.
        "402":
          description: |
            The app's monthly authorize quota is exhausted. No token is issued and,
            because the SDK is fail-closed, your AI calls stop — deliberately.
            Enforcement is never silently degraded to keep traffic flowing.
            Quota notices are emailed before this threshold is reached.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
              example:
                type: https://idside.eu/errors/quota_exceeded
                title: Quota exceeded
                status: 402
                detail: Monthly authorize quota exceeded for this app. Upgrade your plan to continue.
        "403":
          description: |
            Token issuance is stopped. Either this app is under a fraud review
            (`fraud_suspended`), or the organization that owns it is suspended
            and none of its apps are being authorized (`organization_suspended`).
            Consent collection through the widget keeps working in both cases —
            neither an investigation nor an account dispute silences end users.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
              examples:
                fraud_suspended:
                  value:
                    type: https://idside.eu/errors/fraud_suspended
                    title: Fraud suspended
                    status: 403
                    detail: Token issuance is suspended for this app pending a fraud review. Contact support@idside.eu.
                organization_suspended:
                  value:
                    type: https://idside.eu/errors/organization_suspended
                    title: Organization suspended
                    status: 403
                    detail: This organization is suspended, so no tokens are being issued for any of its apps. Contact support@idside.eu.
        "422":
          description: |
            The request is malformed: a required field is missing
            (`invalid_request` — including the case where no user was named at
            all), `user_ref.type` is neither `ubt` nor `user_id`
            (`invalid_user_ref`), or the provider is not one we can enforce
            against (`unknown_provider`, whose `detail` lists the ones that work).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
              examples:
                invalid_request:
                  value:
                    type: https://idside.eu/errors/invalid_request
                    title: Invalid request
                    status: 422
                    detail: "Provide the user this call is for: `user_id` (or `ubt`, if you use the widget)."
                unknown_provider:
                  value:
                    type: https://idside.eu/errors/unknown_provider
                    title: Unknown provider
                    status: 422
                    detail: "Unknown provider 'notaprovider'. Supported: openai, anthropic, gemini, mistral."
                invalid_user_ref:
                  value:
                    type: https://idside.eu/errors/invalid_user_ref
                    title: Invalid user ref
                    status: 422
                    detail: user_ref.type must be 'ubt' or 'user_id'.
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/SigningUnavailable"

  /v1/verify/{token_id}:
    get:
      operationId: verifyToken
      tags: [Verification]
      summary: Check an ACT token against the issuer
      description: |
        Public on purpose: no account, no API key, no permission from us. A
        customer, an auditor, a data protection authority or a journalist holding
        a token id can all check it here. A proof only one party can verify is
        not a proof.

        The answer is **deliberately minimised**. It says whether the token is
        real and still live, and what kind of call it authorized. It does not
        say who the user was (`sub`) or what they consented to (`consent_state`):
        an endpoint that answered "what did this person agree to?" to anyone
        holding a token id would be a privacy hole dressed up as transparency.
        The token itself carries those claims — to the party that already holds
        it, and to nobody else.

        For fully offline verification, fetch
        [`/.well-known/jwks.json`](#operation/jwks) and check the ES256 signature
        with any JWT library. That path needs nothing from this API at all.
      security: []
      parameters:
        - name: token_id
          in: path
          required: true
          description: The `token_id` (JWT `jti`) returned by `POST /v1/authorize`.
          schema:
            type: string
            pattern: "^act_[0-9a-z]+$"
          example: act_01kycmhhx6jw4614ze3gt66n8b
      responses:
        "200":
          description: The token was issued by us. `status` says whether it is still live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenVerification"
        "404":
          description: |
            No token with this id was ever issued by us.

            Note the shape: this is a plain `application/json` answer, not a
            problem document. "Unknown" is the *answer to the question asked*,
            not a failure of the request — a verifier should treat it as a
            verdict and record it, exactly like `revoked`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UnknownToken"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/consent:
    post:
      operationId: recordConsent
      tags: [Consent]
      summary: Record what the user decided (widget only)
      description: |
        The only way a preference ever enters the system. Called by the ID Side
        widget from the end user's browser — from **inside our iframe**, so
        same-origin — and never by a backend, and never from your own page: these
        endpoints are not CORS-enabled, on purpose. See the `publishableKeyQuery`
        security scheme for why that is the guarantee rather than a limitation.

        Three things must all hold before a byte is written:

        1. a valid **publishable key** (`pk_…`), presented from an `Origin` you
           declared for this app in the dashboard;
        2. a single-use **widget handshake token**, minted by us when the widget
           actually rendered (see `POST /v1/widget/bootstrap`) and bound to this
           app and this user. It is burned on use: one token, one write;
        3. a `source` the caller is allowed to claim, and preferences that are
           genuinely JSON booleans.

        On (3), read `source` carefully. Only `widget`, `gpc` and
        `widget_override_gpc` may ever be *claimed* — they say "this came from
        the person, through the browser". `idside_account`, `default_policy` and
        `app_default` are conclusions the server draws about its own state and
        are rejected here; otherwise the strongest provenance the product sells
        could be fabricated in one line.

        On booleans: `{"training": "false"}` — the string — is refused. It reads
        as an opt-out in a token and in the ledger while matching no enforcement
        rule, so no provider parameter would be forced at all. The proof would say
        one thing and the enforcement do the opposite.

        Validation happens in this order, and the first failure is the one you get
        back: authentication, then `Origin`, then required fields, then `source`,
        then `preferences`, then the handshake token, then the GPC override link.
      security:
        - publishableKeyQuery: []
        - publishableKeyBearer: []
      parameters:
        - $ref: "#/components/parameters/OriginHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ConsentRequest"
            examples:
              explicit_choice:
                summary: The user clicked a button in the banner
                value:
                  pk: pk_test_51h8mkq2example0000000000000000
                  user_id: usr_marie_456
                  source: widget
                  preferences:
                    training: false
                  widget_token: eyJraWQiOiJ3Z3QtMjAyNnEzIiwidHlwIjoid2d0K2p3dCIsImFsZyI6IkVTMjU2In0.eyJzdWIiOiJwc3VfZXhhbXBsZSJ9.signature  # pragma: allowlist secret
              gpc_signal:
                summary: The browser sent Global Privacy Control; recorded silently, nothing displayed
                value:
                  pk: pk_test_51h8mkq2example0000000000000000
                  user_id: usr_marie_456
                  source: gpc
                  preferences:
                    training: false
                  widget_token: eyJraWQiOiJ3Z3QtMjAyNnEzIiwidHlwIjoid2d0K2p3dCIsImFsZyI6IkVTMjU2In0.eyJzdWIiOiJwc3VfZXhhbXBsZSJ9.signature
      responses:
        "200":
          description: |
            Recorded. The response carries a **UBT** — a signed User Binding Token
            your backend can pass to `POST /v1/authorize` instead of a flat
            `user_id`, so the proof records that ID Side itself saw this user.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConsentResponse"
        "401":
          description: |
            The publishable key is invalid (`invalid_api_key`), or the widget
            handshake token is missing, expired, already used, or was issued for a
            different app or user (`widget_token_invalid`). Fetch a fresh one from
            `POST /v1/widget/bootstrap` and retry the write once.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
              examples:
                widget_token_invalid:
                  value:
                    type: https://idside.eu/errors/widget_token_invalid
                    title: Widget handshake required
                    status: 401
                    detail: no widget token. Consent can only be recorded from a freshly loaded ID Side widget.
                invalid_api_key:
                  value:
                    type: https://idside.eu/errors/invalid_api_key
                    title: Invalid publishable key
                    status: 401
                    detail: Provide a valid publishable key (pk_...).
        "403":
          $ref: "#/components/responses/OriginNotAllowed"
        "409":
          description: |
            This app declares that it serves the **members of an organization**
            (`subject_kind: members`), so its AI options come from that
            organization's policy and nobody is asked. Nothing was recorded, and
            no User Binding Token was issued.

            An employee's consent is not freely given — the imbalance of power
            makes it unfree — so collecting one here would stage a choice that
            governs nothing. What such a person is owed is not a question but
            transparency: `POST /v1/widget/bootstrap` reports `imposed: true`
            along with the state that applies and the basis it stands on.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
              example:
                type: https://idside.eu/errors/consent_not_collected
                title: This app does not collect consent
                status: 409
                detail: >-
                  This app serves the members of an organization, so its AI options come from
                  that organization's policy and nobody is asked. Nothing was recorded.
        "422":
          description: |
            `invalid_request` (a required field is missing), `invalid_source`
            (a provenance the caller is not allowed to claim),
            `invalid_preferences` (unknown key, missing key, or a value that is
            not a JSON boolean), or `no_gpc_signal_to_override` — an explicit
            override of a GPC signal must point back at a signal we actually
            recorded, or an app could label a fabricated opt-in a "deliberate
            override" and nobody could tell the difference.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
              examples:
                invalid_source:
                  value:
                    type: https://idside.eu/errors/invalid_source
                    title: Invalid source
                    status: 422
                    detail: "source must be one of: widget, gpc, widget_override_gpc"
                invalid_preferences:
                  value:
                    type: https://idside.eu/errors/invalid_preferences
                    title: Invalid preferences
                    status: 422
                    detail: >-
                      preference 'training' must be a boolean (true or false), got "false".
                      A non-boolean is recorded as consent while forcing no provider parameter at all.
                no_gpc_signal_to_override:
                  value:
                    type: https://idside.eu/errors/no_gpc_signal_to_override
                    title: No gpc signal to override
                    status: 422
                    detail: This user has no recorded GPC signal, so there is nothing to override.
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: |
            The choice **was recorded**; only the User Binding Token that normally
            accompanies it could not be signed. This is the one endpoint where a
            `503` does not mean "nothing happened", and the distinction matters:
            the user's preference is stored and already governs their next
            authorization, whether or not the widget ever sees this response.

            So do not re-prompt the user. Retry the write if you want the UBT —
            it is safe, the stored state converges to the same value — but it
            needs a **fresh handshake token**, because the one you sent was
            burned before the signing step. A retry also appends a second consent
            event, which is correct: the ledger records what was attempted, not a
            tidied-up version of it.

            The generic `detail` below is emitted by the shared error handler and
            says "the call was NOT authorized". On this path read that as "no
            token was issued" — nothing was being authorized in the first place.
          headers:
            Retry-After:
              description: Seconds to wait before retrying.
              schema:
                type: integer
                examples: [5]
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
              example:
                type: https://idside.eu/errors/signing_unavailable
                title: Signing unavailable
                status: 503
                detail: The signing service is temporarily unreachable, so no proof could be issued and the call was NOT authorized. Retry shortly.

  /v1/widget/bootstrap:
    post:
      operationId: widgetBootstrap
      tags: [Consent]
      summary: What the widget needs to render, plus the handshake token for one write
      description: |
        Returns the app's public identity, the preferences that currently apply to
        this user (including any inherited from a linked ID Side account), whether
        the consent surface should present itself, and a **single-use handshake
        token** that licenses exactly one subsequent `POST /v1/consent`.

        A `POST` for what reads like a read, and two reasons point the same way.
        It mints a token, so it is not safe to repeat blindly. And a `GET` put
        `user_id` in a query string: this call is same-origin from inside our own
        iframe, so the embedding page never saw it — but every proxy, CDN and WAF
        access log on the path did, and that identifier is meant to reach no log
        in the clear.

        Whether to prompt is decided here, on the server, not in the browser:

        * a Global Privacy Control signal (`Sec-GPC: 1`) → never prompt. The
          browser already answered; the opt-out is recorded silently. Nagging
          someone who deliberately configured their browser not to be asked would
          defeat the purpose of GPC;
        * an explicit choice already made for this app → prompt again only if it
          predates the app's current `policy_version`. A consent is an answer to a
          question; when the question changes, the old answer no longer covers it;
        * a linked ID Side account → no prompt. That deliberate, portable choice
          exists precisely so every app stops asking;
        * nothing recorded → prompt.

        The surface is deliberately **non-blocking**. A cookie banner must block
        because trackers fire on page load; here the AI call happens later,
        server-side, and the default is already conservative — so a user who
        ignores the banner is protected, and no dark pattern is needed to extract
        an answer.
      security:
        - publishableKeyQuery: []
        - publishableKeyBearer: []
      parameters:
        - name: pk
          in: query
          required: true
          description: The app's publishable key.
          schema:
            type: string
            pattern: "^pk_(test|live)_[A-Za-z0-9]+$"
          example: pk_test_51h8mkq2example0000000000000000
        - $ref: "#/components/parameters/OriginHeader"
        - name: Sec-GPC
          in: header
          required: false
          description: |
            The Global Privacy Control signal, as sent by the browser. `1` means
            the user has a standing opt-out; it is reflected in `gpc_signal` and
            suppresses the prompt.
          schema:
            type: string
            enum: ["1"]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_id]
              properties:
                user_id:
                  type: string
                  minLength: 1
                  description: |
                    Your identifier for the signed-in user. Pseudonymised on
                    arrival with a per-app key; the raw value is never stored. In
                    the body rather than the query string so that it lands in no
                    access log.
                  example: usr_marie_456
            example:
              user_id: usr_marie_456
      responses:
        "200":
          description: Bootstrap payload, including the one-shot handshake token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WidgetBootstrap"
        "401":
          $ref: "#/components/responses/InvalidPublishableKey"
        "403":
          $ref: "#/components/responses/OriginNotAllowed"
        "422":
          $ref: "#/components/responses/InvalidRequest"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: |
            No handshake token could be signed, so the widget cannot record a
            choice yet. Render nothing and retry — a consent surface that cannot
            write must not invite an answer it would drop.
          headers:
            Retry-After:
              description: Seconds to wait before retrying.
              schema:
                type: integer
                examples: [5]
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
              example:
                type: https://idside.eu/errors/signing_unavailable
                title: Signing unavailable
                status: 503
                detail: The signing service is temporarily unreachable, so no proof could be issued and the call was NOT authorized. Retry shortly.

  /v1/account/link_requests:
    post:
      operationId: createLinkRequest
      tags: [Account linking]
      summary: Open an account-linking handshake
      description: |
        Starts the flow that connects this app's user to their central ID Side
        account — the mechanism behind "decide once, and every app stops asking".

        The widget cannot link anything itself: it lives in a cross-origin iframe
        where our session cookie is blocked or partitioned. So it asks for an
        opaque link request, opens `popup_url` with `window.open()` — on our own
        domain, where we are first-party and the session behaves normally — and
        then polls `GET /v1/account/link_requests/{id}` until the user has
        confirmed there.

        Polling rather than `postMessage` is deliberate: the user may well finish
        the flow in a *different* tab, the one their magic-link email opened, and
        that tab holds no reference to the popup.

        The request expires ten minutes after creation.
      security:
        - publishableKeyQuery: []
        - publishableKeyBearer: []
      parameters:
        - $ref: "#/components/parameters/OriginHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LinkRequestCreate"
            example:
              pk: pk_test_51h8mkq2example0000000000000000
              user_id: usr_marie_456
      responses:
        "201":
          description: Created. Open `popup_url`, then poll.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LinkRequestCreated"
        "401":
          $ref: "#/components/responses/InvalidPublishableKey"
        "403":
          $ref: "#/components/responses/OriginNotAllowed"
        "422":
          $ref: "#/components/responses/InvalidRequest"

  /v1/account/link_requests/{id}:
    get:
      operationId: getLinkRequest
      tags: [Account linking]
      summary: Poll an account-linking handshake
      description: |
        Polled by the widget while the popup is open. Once `status` is
        `completed`, the response carries the preferences that now apply to this
        user — resolved through the linked account — so the widget can update
        itself without asking anything again.

        Scoped to the app the publishable key belongs to: a link request created
        for another app is indistinguishable from one that does not exist.
      security:
        - publishableKeyQuery: []
        - publishableKeyBearer: []
      parameters:
        - name: id
          in: path
          required: true
          description: The `link_request_id` returned when the handshake was opened.
          schema:
            type: string
            pattern: "^lreq_[0-9a-z]+$"
          example: lreq_zyynbzxdl9pcrrzdvuekijj6
        - name: pk
          in: query
          required: true
          description: The app's publishable key.
          schema:
            type: string
            pattern: "^pk_(test|live)_[A-Za-z0-9]+$"
          example: pk_test_51h8mkq2example0000000000000000
        - $ref: "#/components/parameters/OriginHeader"
      responses:
        "200":
          description: The current state of the handshake.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LinkRequestStatus"
        "401":
          $ref: "#/components/responses/InvalidPublishableKey"
        "403":
          $ref: "#/components/responses/OriginNotAllowed"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/ledger/entries:
    get:
      operationId: listLedgerEntries
      tags: [Ledger]
      summary: Read your app's ledger
      description: |
        Your own evidence, newest first. One entry per authorization: what was
        asked for, whose preferences applied and where they came from, which
        provider parameters were forced, which token was issued, and the two
        hashes that chain the entry to the one before it.

        Entries are append-only. There is no endpoint to edit or delete one, and
        the database refuses it as well — a correction is always a new entry.

        `user_pseudo_id` is the per-app pseudonym of a user, not their identifier
        in your systems. To find someone's entries, pseudonymise on your side with
        the value shown in the dashboard, or filter on the entries you already
        hold.

        **Paging.** Read the first page, then pass the `next_cursor` you were
        given back as `before_seq`, until `next_cursor` is `null`. That walks the
        whole chain, however long it is — which is what makes archiving your own
        ledger possible.

        The cursor is a sequence number, and that is deliberate: `seq` is
        assigned under a per-app lock, never reused and never renumbered, and
        entries are never deleted. So a page is the same set of entries whenever
        you ask for it, no matter how much was appended in between. An offset
        would not be: every new entry would shift the pages under you, and a walk
        backwards would skip entries without ever telling you.
      security:
        - secretKey: []
      parameters:
        - name: user_pseudo_id
          in: query
          required: false
          description: Restrict to one user of this app.
          schema:
            type: string
            pattern: "^psu_[0-9a-f]{32}$"
          example: psu_1860e967f634ef7e6d645f49567a5fac
        - name: from
          in: query
          required: false
          description: Only entries whose `occurred_at` is at or after this instant.
          schema:
            type: string
            format: date-time
          example: "2026-07-01T00:00:00Z"
        - name: to
          in: query
          required: false
          description: Only entries whose `occurred_at` is at or before this instant.
          schema:
            type: string
            format: date-time
          example: "2026-07-31T23:59:59Z"
        - name: before_seq
          in: query
          required: false
          description: |
            Return only entries below this sequence number, exclusive. Pass the
            `next_cursor` from the previous page and nothing else — a value that
            is not a sequence number is rejected rather than treated as no
            cursor, so a paging loop cannot silently restart from the top.
          schema:
            type: integer
            minimum: 1
          example: 4821
        - name: limit
          in: query
          required: false
          description: |
            How many entries to return. Values above 500 are clamped to 500;
            values below 1, and anything that is not a number, fall back to the
            default.
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 50
      responses:
        "200":
          description: |
            A page of entries, plus `head_seq` — the highest sequence number this
            app has, so you can tell whether you have caught up — and the cursor
            for the next page.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LedgerPage"
        "401":
          $ref: "#/components/responses/InvalidApiKey"
        "403":
          $ref: "#/components/responses/InsufficientScope"
        "422":
          $ref: "#/components/responses/InvalidRequest"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/ledger/verify:
    get:
      operationId: verifyLedger
      tags: [Ledger]
      summary: Re-verify the hash chain
      description: |
        Recomputes your app's chain from genesis and reports exactly where it
        broke, if it did — a missing entry, a `previous_hash` that does not line
        up, or an entry whose contents no longer match the hash committed for it.

        Run it yourself, on a schedule. The guarantee this product sells is not
        "we promise we never rewrote your ledger"; it is "here is how you check,
        and here is what a break would look like".

        Each call walks your whole chain, so this endpoint is rate limited more
        tightly than the rest of the API: ten a minute per key. That is far above
        any schedule and far below a loop. To check a long ledger in pieces
        instead, bound the walk with `from_seq` and `to_seq`.

        A rewritten or removed entry invalidates every link after it, so a break
        at sequence *n* tells you the tampering happened at or before *n*.
      security:
        - secretKey: []
      parameters:
        - name: from_seq
          in: query
          required: false
          description: |
            Start verifying at this sequence number instead of at genesis. The
            entry immediately before it must exist, since the chain is anchored on
            its hash; if it does not, the answer is a break, not an error.
          schema:
            type: integer
            minimum: 1
          example: 1
        - name: to_seq
          in: query
          required: false
          description: Stop at this sequence number instead of at the head.
          schema:
            type: integer
            minimum: 1
          example: 5000
      responses:
        "200":
          description: |
            The verdict. `200` means the check ran, not that the chain is intact —
            read `intact`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LedgerVerification"
        "401":
          $ref: "#/components/responses/InvalidApiKey"
        "429":
          $ref: "#/components/responses/RateLimited"

  /.well-known/jwks.json:
    get:
      operationId: jwks
      tags: [Verification]
      summary: The public keys our proofs are signed with
      description: |
        Published so that a customer, an auditor or a regulator can verify an ACT
        token entirely offline — no account, no API key, and no cooperation from
        us. This endpoint is what makes the whole scheme falsifiable.

        Two key purposes appear here: `act` (the proof handed to providers and
        auditors) and `ubt` (the binding between a widget-seen user and a backend
        call). Each purpose has its own key, so a token of one kind can never be
        replayed as the other. The widget handshake key is deliberately absent: it
        proves nothing to anyone outside our own doors.

        Select the key by the `kid` in the token header, and pin the algorithm to
        `ES256` when verifying — a token whose header claims `none` or an HMAC
        algorithm must be rejected outright, never negotiated. Keys rotate
        quarterly; a retired public key stays published until the last token
        signed with it has expired. Cache for an hour, and re-fetch on an unknown
        `kid` rather than at a fixed interval.
      security: []
      responses:
        "200":
          description: A JWK Set. Cacheable for one hour.
          headers:
            Cache-Control:
              description: Public, one hour.
              schema:
                type: string
                examples: ["max-age=3600, public"]
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Jwks"
        "503":
          description: |
            The key set is temporarily unavailable — our vault did not answer.

            **This says nothing about the proofs you already hold.** A token
            signed last week is exactly as valid during this outage as it was
            before it; the only thing missing is our ability to hand you the
            public half right now. Retry rather than concluding anything: reading
            an unreachable JWKS as "signature invalid" would turn our downtime
            into a false accusation against a customer's evidence, which is the
            opposite of what this endpoint exists for.

            Two things worth building for. Keep the last key set you fetched —
            verification is offline anyway, and yesterday's copy verifies today's
            tokens. And note that this failure is deliberately **not** cached
            (`no-cache`), unlike the hour of freshness on a successful fetch: an
            outage must never be held by an intermediary between us and an
            auditor.
          headers:
            Retry-After:
              description: Seconds to wait before retrying.
              schema:
                type: integer
                examples: [5]
            Cache-Control:
              description: Never cached, unlike the successful response.
              schema:
                type: string
                examples: [no-cache]
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
              example:
                type: https://idside.eu/errors/signing_unavailable
                title: Signing unavailable
                status: 503
                detail: The key set is temporarily unavailable. Proofs already issued remain valid — retry shortly rather than treating them as unverifiable.

  /openapi.yaml:
    get:
      operationId: openapiDocument
      tags: [Verification]
      summary: This document
      description: |
        The contract itself, served from the running application so that what you
        generate a client from is what that deployment actually implements. A
        request spec in the codebase fails the build if a route exists without a
        matching entry here, or an entry here without a matching route.
      security: []
      responses:
        "200":
          description: This OpenAPI document.
          content:
            application/yaml:
              schema:
                type: string

components:
  securitySchemes:
    secretKey:
      type: http
      scheme: bearer
      bearerFormat: sk_live_… / sk_test_…
      description: |
        Your app's **secret key**, from the dashboard, in the `Authorization`
        header. Backend only. It is shown once, at creation. If it leaks, revoke
        it in the dashboard — tokens already issued can be revoked too.

        **A key carries scopes, and only what it was granted.** There are two:
        `authorize` (`POST /v1/authorize`) and `ledger:read` (both ledger
        endpoints). A key minted without saying anything gets `authorize` alone —
        so the key sitting on your app servers can make AI calls and cannot read
        back your evidence file. Grant `ledger:read` to a separate key, on the
        machine that actually archives, and one leaked key stops being a copy of
        everything you have. Using a key outside its scopes answers
        `insufficient_scope` (403), never silence.

        Keys are stored only as digests; a dump of our database yields no usable
        key. `sk_test_…` keys never bill and never block on quota, and their
        tokens are issued by a test issuer so they can never be mistaken for
        production proof.
    publishableKeyQuery:
      type: apiKey
      in: query
      name: pk
      description: |
        Your app's **publishable key**. Public by design — it ships in your page
        source — so it is worthless on its own. What actually guards these
        endpoints is the `Origin` check plus the single-use widget handshake
        token.

        On `POST` operations the same key is normally sent as a `pk` field in the
        JSON body, which is what the widget does; the query parameter and the
        bearer form are accepted equivalently.

        ### These endpoints are not callable from your own page

        No CORS. No `Access-Control-Allow-Origin`, no preflight — deliberately,
        and permanently. The only browser context that reaches them is the ID
        Side iframe, which we serve, so its calls are same-origin.

        That is not a packaging detail, it is the guarantee you are buying. Your
        page hosts a cross-origin iframe and cannot read or write inside it,
        which is exactly why a preference may be recorded there and nowhere else.
        A preference your own page could write would be a preference you could
        fabricate, and the proof attached to it would certify nothing. Use
        `IDSide.init()` and the widget; there is no browser-side alternative, and
        there will not be one.
    publishableKeyBearer:
      type: http
      scheme: bearer
      bearerFormat: pk_live_… / pk_test_…
      description: The publishable key in the `Authorization` header, equivalent to the `pk` parameter.

  parameters:
    OriginHeader:
      name: Origin
      in: header
      required: false
      description: |
        Sent automatically by the browser, and **not on every request** — which is
        why this is not marked required. Per the Fetch standard a browser appends
        `Origin` when a request is cross-origin, or when its method is neither
        `GET` nor `HEAD`. The ID Side widget reaches these endpoints
        **same-origin**, from inside the iframe we serve, so a `GET` here
        carries no `Origin` at all and a `POST` carries *ours*.

        When it is present it must be either a domain you declared for this app
        or our own origin.

        Be clear about what this buys: it is **not** authentication. `Origin` is
        enforced by browsers; a non-browser client sets it to whatever it likes,
        and the publishable key it accompanies is public anyway. It stops a
        third-party site from driving your widget from its own page. It does not
        stop you, and nothing could — the trust model here is deterrent and
        evidentiary, not a cage. What actually guards a consent write is the
        single-use handshake token.
      schema:
        type: string
        format: uri
      example: https://medhelp.example

  responses:
    RateLimited:
      description: |
        Too many requests. Limits are generous and keyed by API key on the
        authorize path (customers call from a handful of backend hosts, so an IP
        limit would punish a busy customer and miss an abusive one) and by IP
        elsewhere. `Retry-After` says how long to wait. If you hit this with
        legitimate traffic, contact support and the limit will be raised — the
        authorize path is fail-closed, so throttling you throttles your product.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
            examples: [60]
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          example:
            type: https://idside.eu/errors/rate_limited
            title: Too many requests
            status: 429
            detail: Slow down and retry in 60 seconds. If you are hitting this with legitimate traffic, contact support and we will raise your limit.
    SigningUnavailable:
      description: |
        The signing service is temporarily unreachable, so no proof could be
        issued — and therefore **nothing was authorized**. This is a refusal, not
        a degraded success: answering anything else would let an AI call go out
        unverified, which is the single failure this system exists to prevent.
        Transient and on our side; retry after `Retry-After`.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
            examples: [5]
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          example:
            type: https://idside.eu/errors/signing_unavailable
            title: Signing unavailable
            status: 503
            detail: The signing service is temporarily unreachable, so no proof could be issued and the call was NOT authorized. Retry shortly.
    InvalidApiKey:
      description: Missing, revoked, or not a secret key.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          example:
            type: https://idside.eu/errors/invalid_api_key
            title: Invalid API key
            status: 401
            detail: Provide a valid secret key (sk_...) in the Authorization header.
    InvalidPublishableKey:
      description: Missing, revoked, or not a publishable key.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          example:
            type: https://idside.eu/errors/invalid_api_key
            title: Invalid publishable key
            status: 401
            detail: Provide a valid publishable key (pk_...).
    InsufficientScope:
      description: |
        The key is valid, and it was not granted this right. Scopes are chosen
        when a key is created: a key minted with nothing said authorizes AI calls
        and cannot read the ledger, so that one leaked key is not a copy of your
        whole record. Mint a key with the scope you need — retrying with this one
        will not help.
      headers:
        WWW-Authenticate:
          description: '`Bearer error="insufficient_scope", scope="<the scope that would have served>"`'
          schema:
            type: string
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          example:
            type: https://idside.eu/errors/insufficient_scope
            title: Insufficient scope
            status: 403
            detail: >-
              This key does not carry the `ledger:read` scope; it was minted with
              authorize. Scopes are granted when a key is created and a key is never
              widened in place — mint one with `ledger:read` in your ID Side
              dashboard. This is why one leaked key is not a copy of everything you
              have.
    OriginNotAllowed:
      description: The `Origin` is not a domain declared for this app. Add it in the dashboard.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          example:
            type: https://idside.eu/errors/origin_not_allowed
            title: Origin not allowed
            status: 403
            detail: https://evil.example is not a declared domain for this app. Add it in your ID Side dashboard.
    InvalidRequest:
      description: A required field is missing or malformed.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          example:
            type: https://idside.eu/errors/invalid_request
            title: Invalid request
            status: 422
            detail: "param is missing or the value is empty or invalid: user_id"
    NotFound:
      description: |
        No such resource for this app. Says nothing about whether the identifier
        exists elsewhere: identifiers here are app-scoped, and confirming one in
        someone else's app would be a cross-tenant oracle.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          example:
            type: https://idside.eu/errors/not_found
            title: Not found
            status: 404
            detail: No such resource for this app.

  schemas:
    Problem:
      type: object
      title: Problem document (RFC 9457)
      description: |
        Every error this API returns, served as `application/problem+json`.
        Branch on `type`; it is stable. `title` and `detail` are written for a
        human reading a log and may be reworded at any time.
      required: [type, title, status]
      properties:
        type:
          type: string
          format: uri
          description: |
            The stable, machine-readable identifier for this failure. The full
            catalogue:

            | `type` | Status | Meaning |
            | --- | --- | --- |
            | `invalid_api_key` | 401 | Key missing, revoked, or of the wrong kind. |
            | `ubt_required` | 401 | This app refuses a flat `user_id`; send a UBT. |
            | `ubt_invalid` | 401 | UBT is unsigned, expired, or for another app. |
            | `widget_token_invalid` | 401 | Handshake token missing, expired, reused, or mismatched. |
            | `quota_exceeded` | 402 | Monthly authorize quota exhausted. |
            | `fraud_suspended` | 403 | Token issuance suspended for this app pending review. |
            | `organization_suspended` | 403 | The organization that owns this app is suspended. |
            | `insufficient_scope` | 403 | The key is valid but was not granted this right. |
            | `origin_not_allowed` | 403 | `Origin` is not a declared domain for this app. |
            | `not_found` | 404 | No such resource for this app. |
            | `consent_not_collected` | 409 | This app serves members; nobody is asked, so nothing is recorded. |
            | `invalid_request` | 422 | A required field is missing or empty. |
            | `invalid_user_ref` | 422 | `user_ref.type` is neither `ubt` nor `user_id`. |
            | `unknown_provider` | 422 | No enforcement rules exist for that provider. |
            | `invalid_source` | 422 | A provenance the caller may not claim. |
            | `invalid_preferences` | 422 | Unknown key, missing key, or a non-boolean value. |
            | `no_gpc_signal_to_override` | 422 | No recorded GPC signal to override. |
            | `rate_limited` | 429 | Too many requests; see `Retry-After`. |
            | `signing_unavailable` | 503 | We could not sign, so nothing was authorized. Retry. |
          enum:
            - https://idside.eu/errors/invalid_api_key
            - https://idside.eu/errors/ubt_required
            - https://idside.eu/errors/ubt_invalid
            - https://idside.eu/errors/widget_token_invalid
            - https://idside.eu/errors/quota_exceeded
            - https://idside.eu/errors/fraud_suspended
            - https://idside.eu/errors/organization_suspended
            - https://idside.eu/errors/insufficient_scope
            - https://idside.eu/errors/origin_not_allowed
            - https://idside.eu/errors/not_found
            - https://idside.eu/errors/consent_not_collected
            - https://idside.eu/errors/invalid_request
            - https://idside.eu/errors/invalid_user_ref
            - https://idside.eu/errors/unknown_provider
            - https://idside.eu/errors/invalid_source
            - https://idside.eu/errors/invalid_preferences
            - https://idside.eu/errors/no_gpc_signal_to_override
            - https://idside.eu/errors/rate_limited
            - https://idside.eu/errors/signing_unavailable
        title:
          type: string
          description: A short human-readable summary. Not stable; do not branch on it.
          examples: [Invalid API key]
        status:
          type: integer
          description: The HTTP status code, repeated in the body.
          minimum: 400
          maximum: 599
          examples: [401]
        detail:
          type: string
          description: |
            What went wrong in this specific case, written to be actionable by the
            developer reading it. Omitted when there is nothing to add.
          examples: [Provide a valid secret key (sk_...) in the Authorization header.]

    Preferences:
      type: object
      title: Consent state
      description: |
        What the user has agreed to. A closed set of keys with strictly boolean
        values — nothing else is storable, at any door. `false` means "not
        allowed"; the conservative default for a user nobody has heard from is
        `{"training": false}`.

        Only `training` is required. It is the original single question, carried
        by every state ever written. The rest come from the shared consent
        catalogue the SDKs and the published provider matrix are generated from,
        and they are optional **while** existing states are migrated onto them
        and users are re-asked on the questions they have never seen. An absent
        key means "never asked", which is neither a refusal nor an agreement: a
        rule matching on it simply does not fire.
      required: [training]
      additionalProperties: false
      properties:
        training:
          type: boolean
          description: |
            Whether this user's data may be used to train or improve provider
            models. `false` is what makes ID Side force the corresponding provider
            parameter, for example OpenAI's `store: false`.

            The original, coarse question. The catalogue axes below split it into
            what each provider can actually honour.
        training.foundation:
          type: boolean
          description: |
            Train general AI models on my conversations

            Your prompts and the answers you receive may be used to train the provider's general-purpose models, which serve every other user.
        training.personalisation:
          type: boolean
          description: |
            Personalise my experience from my history

            Your data may be used to tailor answers to you specifically. This does not feed the general models.
        training.analytics:
          type: boolean
          description: |
            Aggregated statistics to improve the service

            Anonymous, aggregated measurements. No individual content is retained.
        memory:
          type: boolean
          description: |
            Remember me between conversations

            The assistant keeps a persistent memory of you across sessions.
        agentic_tools:
          type: boolean
          description: |
            Let the AI use tools that store my data elsewhere

            Web search, code execution, file stores, connectors and autonomous agents. These bring retention back in even when everything else is switched off.
        data_residency:
          type: boolean
          description: |
            Keep processing inside my region

            Where the AI actually runs. Refusing regional processing may mean your data is handled outside your jurisdiction.
        retention.conversation:
          type: boolean
          description: |
            Store my conversations on the provider's servers

            Server-side history so the assistant can resume where you left off. Note: no provider offers absolute zero — content flagged by trust & safety is retained regardless (up to 2 years at one provider).
        retention.cache:
          type: boolean
          description: |
            Keep my prompts in a fast cache

            Prompts are cached to speed up and cheapen repeated calls, typically for up to 24 hours. Note: no provider offers absolute zero — content flagged by trust & safety is retained regardless (up to 2 years at one provider).
        retention.files:
          type: boolean
          description: |
            Keep files I upload on the provider's servers

            Documents and images sent to the AI are stored until deleted. Note: no provider offers absolute zero — content flagged by trust & safety is retained regardless.
        location:
          type: boolean
          description: |
            Use my approximate location

            City or region level, inferred or declared, used to adapt answers.
        human_review:
          type: boolean
          description: |
            Allow humans to review my conversations

            Provider staff or contractors may read your exchanges, typically for quality or safety.
      examples:
        - training: false
          agentic_tools: false

    ConsentSource:
      type: string
      title: Where a preference came from
      description: |
        Provenance, and it is load-bearing: an explicit choice always beats an
        automatic signal, and between two explicit choices the more specific one
        wins. Only the first three may ever be *claimed* by a caller; the rest are
        conclusions the server draws about its own state.
      enum: [widget, gpc, widget_override_gpc, idside_account, default_policy, app_default]
      x-meanings:
        widget: The user answered the consent surface for this app.
        gpc: The browser sent Global Privacy Control; the opt-out was recorded silently.
        widget_override_gpc: The user deliberately opted back in for this app, overriding their GPC signal.
        idside_account: Inherited from the user's central ID Side account — explicit, and portable across apps.
        default_policy: An organisational policy applies. No developer can override it.
        app_default: Nobody has said anything; the app's conservative default is in force.

    LegalBasis:
      type: string
      title: Legal basis
      description: |
        The basis recorded in the proof and in the ledger for this processing.

        `not_established` is not an Article 6 basis — it is the honest statement
        that none was established *here*. It goes with `source: app_default`: the
        user has never answered, so there was no positive act to record, and the
        app's own conservative default is what applied. You may well have a valid
        basis for that call; ID Side did not witness it and will not sign for it.
      enum: [consent, legitimate_interest, contractual_obligation, not_established]
      x-meanings:
        consent: The user performed a positive act — the widget, or their ID Side account.
        legitimate_interest: Reserved for organisational policies (Article 6(1)(f)).
        contractual_obligation: Reserved for organisational policies (Article 6(1)(b)).
        not_established: Nobody answered. The app's default posture applied; this is not consent.

    IdentityBinding:
      type: string
      title: How strongly the user was identified
      description: |
        Recorded in the token so an auditor can see how strong the identity link
        actually was. `ubt` means the widget saw this user and ID Side signed the
        binding; `user_id` means the developer asserted it.
      enum: [ubt, user_id]

    AuthorizeRequest:
      type: object
      title: Authorize request
      description: |
        Name the user in exactly one of three ways — `user_ref`, `ubt` or
        `user_id`, checked in that order — and describe the AI call by its hash.
        No prompt content is ever sent.
      required: [provider, model, request_hash]
      properties:
        user_id:
          type: string
          minLength: 1
          description: |
            Your identifier for the user. Pseudonymised on arrival with a per-app
            key; the raw value never reaches our database, our logs or the ledger.
          examples: [usr_marie_456]
        ubt:
          type: string
          description: |
            A User Binding Token issued by `POST /v1/consent` when the widget saw
            this user. Preferred over a flat `user_id`: it makes the proof say that
            we saw the user, not merely that you said so. Valid for 24 hours.
          examples: [eyJraWQiOiJ1YnQtMjAyNnEzIiwidHlwIjoidWJ0K2p3dCJ9.eyJzdWIiOiJwc3VfZXhhbXBsZSJ9.signature]
        user_ref:
          type: object
          description: The explicit form of the two fields above.
          required: [type, value]
          properties:
            type:
              type: string
              enum: [ubt, user_id]
            value:
              type: string
              minLength: 1
        provider:
          type: string
          description: |
            The AI provider this call is going to. It must be one we hold
            enforcement rules for — otherwise we would be signing a proof about a
            request we cannot constrain.
          examples: [openai, anthropic, gemini, mistral]
        model:
          type: string
          description: |
            The model being called. Not decoration: enforcement genuinely differs
            by model, not merely by provider, so the parameters forced for the same
            consent state can differ between two models of the same vendor.
          examples: [gpt-4o]
        request_hash:
          type: string
          description: |
            A hash of the outbound provider request, computed by the SDK as
            `sha256:` followed by the SHA-256 of the RFC 8785 (JSON
            Canonicalization Scheme) form of the request body. It binds the token
            to this one request and is the reason a token cannot be reused.

            It is opaque to us: we never parse it, and it appears verbatim in the
            token and the ledger. Compute it the same way in every language, or a
            verifier will not be able to reproduce it — the cross-language test
            vectors published with the open-source SDKs are the reference.
          examples: ["sha256:9f2c1ab4d3e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f"]
        call_site:
          type: string
          description: |
            The provider endpoint the SDK is about to call, in the shared
            descriptors' spelling (`responses.create`, `chat.completions.create`,
            `messages.create`…).

            Optional, and mechanics rather than policy. It exists because a
            provider's endpoints do not accept the same parameters: `background`
            is a retention control on OpenAI's `/v1/responses` and does not exist
            on `/v1/chat/completions`, so a decision made per provider could only
            drop the lever or break the call. Omit it and you get the parameters
            that are safe on every endpoint of that provider — which is exactly
            what an SDK released before this field does, and it keeps working.
          examples: [responses.create]
        credential_postures:
          type: array
          items:
            type: string
          description: |
            The posture LABELS you have provisioned for this provider — never the
            credentials themselves, which stay on your machine. Read only so we
            can tell you when a call needs a posture you have not set up; the
            decision does not depend on it.
          examples: [["default", "no_training"]]
      anyOf:
        - required: [user_ref]
        - required: [ubt]
        - required: [user_id]

    AuthorizeResponse:
      type: object
      title: Authorize response
      required:
        [decision, consent_state, source, legal_basis, binding, params_forced, mode, act_token,
         token_id, expires_at]
      properties:
        decision:
          const: allow
          description: |
            Always `allow` on a `2xx`. A refusal is not a decision field to inspect —
            it is a `4xx` problem document, so a client that forgets to check cannot
            accidentally proceed.
        consent_state:
          $ref: "#/components/schemas/Preferences"
        source:
          $ref: "#/components/schemas/ConsentSource"
        legal_basis:
          $ref: "#/components/schemas/LegalBasis"
        binding:
          $ref: "#/components/schemas/IdentityBinding"
        mode:
          allOf:
            - $ref: "#/components/schemas/TokenMode"
          description: |
            The mode this call was authorized in, repeated outside the token so
            you can assert it without decoding a JWT. It is never `null` here —
            only an old stored token can be silent about its mode.

            Worth one line in your own integration tests: a production
            deployment still holding an `sk_test_` key produces rehearsals where
            you expected evidence, and nothing else in this response would tell
            you.
        params_forced:
          type: object
          additionalProperties: true
          description: |
            The provider parameters that **must** be applied to this call. Merge
            them into your request and do not let the caller override them; the SDK
            enforces exactly that. An empty object means this consent state forces
            nothing for this provider and model.

            The mapping lives here, on the server, deliberately: when a provider
            renames a flag or a model starts mandating retention, we update a rule
            rather than shipping an SDK and waiting for thousands of customers to
            upgrade.
          examples:
            - store: false
        constraints:
          type: object
          additionalProperties: true
          description: |
            What the SDK must **refuse**, as opposed to what it must set — today
            only `{"tools": "refuse"}` or `{"tools": "strip"}`. Absent or empty
            means no restriction.

            Kept apart from `params_forced` because it is a different authority.
            A forced parameter writes a value into your request; ID Side is never
            allowed to do that to `tools`, which is why it sits in the forbidden
            list. A constraint rejects the call instead, and the SDK raises
            `ToolUseRefusedError` before anything reaches the provider.

            It follows from the user's consent state alone. **You do not tell us
            whether this call carries tools, and we do not ask**: the SDK answers
            that question on your machine and acts on it there. An SDK that does
            not understand a constraint fails closed rather than ignoring it.
          examples:
            - tools: refuse
        credential_posture:
          type: [string, "null"]
          description: |
            The credential this call must go out on, named by the label you gave
            it. `null` when this user's answer requires nothing in particular.

            No provider exposes a training opt-out as a request parameter — the
            setting lives on the credential (a project, a workspace, an
            organisation). Provision one per posture and the SDK sends each call
            on the one matching that user's answer, which is the only way an
            account-level setting becomes a per-person one.

            We name what is **required**, never what you happen to hold. If you
            have not provisioned it, the SDK raises `MissingCredentialError`
            rather than sending the call on another credential — falling back
            would place the user under a regime they declined, silently.
          examples: [no_training]
        act_token:
          type: string
          description: |
            The proof: an ES256 JWT, verifiable offline against
            `/.well-known/jwks.json`. Its claims include the consent state, the
            source, the legal basis, the provider and model, the `request_hash`, a
            single-use `nonce` and a one-hour expiry. Attach it to the provider
            call and keep it with your own records.
        token_id:
          type: string
          pattern: "^act_[0-9a-z]+$"
          description: The token's `jti`. Hand it to anyone who needs to check the proof.
          examples: [act_01kycmhhx6jw4614ze3gt66n8b]
        expires_at:
          type: string
          format: date-time
          description: |
            One hour after issuance. After this the token is spent for good — it is
            not refreshable, because it certifies one request, not a session.
          examples: ["2026-07-25T13:38:31Z"]

    TokenVerification:
      type: object
      title: Token verification result
      description: |
        Deliberately minimised: whether the proof is real and live, and what kind
        of call it covered. Never who the user was, never what they consented to.
      required:
        [status, mode, issued_at, expires_at, app_id, provider, model, source, legal_basis, binding,
         revoked_at, revocation_reason]
      properties:
        status:
          type: string
          enum: [valid, expired, revoked]
          description: |
            `valid` — issued by us, not revoked, not past its expiry.
            `expired` — genuine, but past its one-hour window; a call made while it
            was live is still proven, so treat this as evidence of age, not of
            forgery.
            `revoked` — withdrawn by the customer after the key that minted it was
            compromised. Read `revocation_reason` before drawing a conclusion: it
            does **not** say the call was unconsented.
        mode:
          $ref: "#/components/schemas/TokenMode"
        issued_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
        app_id:
          type: string
          description: The public identifier of the app the token was issued to.
          examples: [app_medhelp]
        provider:
          type: string
          examples: [openai]
        model:
          type: string
          examples: [gpt-4o]
        source:
          $ref: "#/components/schemas/ConsentSource"
        legal_basis:
          $ref: "#/components/schemas/LegalBasis"
        binding:
          $ref: "#/components/schemas/IdentityBinding"
        revoked_at:
          type: [string, "null"]
          format: date-time
          description: |
            When the proof was withdrawn, or `null`. Always present, so a
            verifier can read it unconditionally rather than guess whether its
            absence means "not revoked" or "an older response shape".

            Note the ordering it lets you check: a call made **before** this
            moment was authorized while the proof was live.
        revocation_reason:
          type: [string, "null"]
          enum: [api_key_compromised, null]
          description: |
            Why, in a closed vocabulary you can branch on. `null` unless the
            proof was withdrawn.

            `api_key_compromised` — the customer reported that the secret key
            which minted this proof had leaked, and asked us to withdraw what it
            signed. **This is a statement about the key, not about the consent.**
            The end user's decision was resolved and recorded exactly as it
            always is, and the customer's ledger entry for the call still stands:
            revocation withdraws forward validity, it does not retroactively
            claim the consent was never obtained. A verifier that cannot tell
            those apart will read a customer's own diligence as a confession.

            Revocation is deliberately **opt-in** on our side: an ordinary key
            rotation withdraws nothing, because marking an hour of proofs of
            genuinely consented calls as void — permanently, with no way back —
            for no operational benefit would destroy evidence for nothing.

            The ledger is never rewritten for this. It records that the call was
            authorized, which remains true; the withdrawal lives on the token's
            own row, and `GET /v1/ledger/verify` still reports the chain intact.

    TokenMode:
      title: Token mode
      type: [string, "null"]
      enum: [test, live, null]
      description: |
        Whether this proof is a rehearsal or evidence.

        It is taken from the **key** that authorized the call — the `sk_test_` or
        `sk_live_` prefix a developer pasted into one environment — and never
        from the app's current mode, which can move when a customer goes live.
        Reading the app would relabel a staging integration's proofs the instant
        production went live, retroactively and without anybody touching that
        deployment.

        `test` is not a lesser proof of a lesser call: it is a statement that
        nobody claimed this call was real. Treat a `test` token as **not
        evidence**. Going live is one-way, so a `live` proof never becomes a
        `test` one afterwards.

        `null` only ever appears on a token minted before this claim existed. Its
        signature covers no mode, so we do not invent one — a value here would be
        an assertion the signature does not cover.
      examples: [test]

    UnknownToken:
      type: object
      title: Unknown token
      description: A verdict, not an error — we never issued a token with this id.
      required: [status]
      properties:
        status:
          const: unknown

    ConsentRequest:
      type: object
      title: Consent write
      required: [user_id, source, preferences, widget_token]
      properties:
        pk:
          type: string
          pattern: "^pk_(test|live)_[A-Za-z0-9]+$"
          description: |
            The app's publishable key. Sent in the body by the widget; the `pk`
            query parameter and `Authorization: Bearer pk_…` are equivalent.
          examples: [pk_test_51h8mkq2example0000000000000000]
        user_id:
          type: string
          minLength: 1
          description: The app's identifier for the user, pseudonymised on arrival.
          examples: [usr_marie_456]
        source:
          type: string
          enum: [widget, gpc, widget_override_gpc]
          description: |
            How the user expressed this. Only these three may be claimed — they all
            mean "this came from the person, through the browser". Claiming
            `idside_account`, `default_policy` or `app_default` is refused with
            `invalid_source`.
        preferences:
          $ref: "#/components/schemas/Preferences"
        widget_token:
          type: string
          description: |
            The single-use handshake token from `POST /v1/widget/bootstrap`. Bound
            to this app and this user, valid 15 minutes, and burned on use — one
            token, one write. Without it a `curl` with a forged `Origin` could
            fabricate a consent record, which is exactly what this closes.

    ConsentResponse:
      type: object
      title: Consent write result
      required: [consent_id, consent_state, source, ubt]
      properties:
        consent_id:
          type: integer
          description: Our identifier for the stored consent record.
        consent_state:
          $ref: "#/components/schemas/Preferences"
        source:
          $ref: "#/components/schemas/ConsentSource"
        ubt:
          type: string
          description: |
            A User Binding Token: an ES256 JWT binding this user's pseudonym to
            this app, carrying a hash of the consent state it was minted against,
            valid 24 hours. Pass it to `POST /v1/authorize` in place of a flat
            `user_id` and the proof records the stronger `binding: ubt`.

            If the user changes their mind afterwards, the token is still accepted —
            that is the product working — but their *current* preferences are
            resolved and the ledger records that the binding was made against a
            state that no longer holds.

    WidgetBootstrap:
      type: object
      title: Widget bootstrap payload
      required:
        [app, consent_state, source, linked_account, gpc_signal, policy_version, prompt, decided, widget_token]
      properties:
        app:
          type: object
          required: [id, name]
          properties:
            id:
              type: string
              examples: [app_medhelp]
            name:
              type: string
              examples: [MedHelp]
        consent_state:
          $ref: "#/components/schemas/Preferences"
        source:
          $ref: "#/components/schemas/ConsentSource"
        linked_account:
          type: boolean
          description: Whether this user is linked to a central ID Side account.
        gpc_signal:
          type: boolean
          description: "Whether this request carried the `Sec-GPC: 1` header."
        policy_version:
          type: integer
          minimum: 1
          description: |
            The app's current AI privacy policy version, bumped by the customer when
            the policy changes.
        decided_policy_version:
          type: [integer, "null"]
          description: |
            The version the user actually answered, or `null` if they never have. A
            consent is an answer to a question; when it predates `policy_version`,
            the old answer no longer covers the new one.
        prompt:
          type: boolean
          description: Whether the consent surface should present itself now.
        unanswered:
          type: array
          items:
            type: string
          description: |
            The options this app asks that this user has no answer for — because
            the app started calling a provider that asks something new, or
            because their answer predates the question.

            "No answer" means the key is **absent**. `false` is a refusal, which
            is an answer, and re-asking it is how consent fatigue is made. The
            surface re-asks on exactly these and marks them; consent is never
            presumed on a question that was never put, which is also why a
            registry rule matching an absent key does not fire.
          examples: [["retention.conversation", "retention.cache"]]
        imposed:
          type: boolean
          description: |
            Whether this app decides FOR this person rather than asking them —
            `true` exactly when the app declares `subject_kind: members`.

            When it is true the surface must put no question and offer no switch:
            `prompt` is `false` and `unanswered` is empty, whatever this person
            has answered anywhere else. What they are owed instead is
            transparency — `consent_state` is what applies to them and
            `legal_basis` is what it stands on.
        legal_basis:
          $ref: "#/components/schemas/LegalBasis"
        decided:
          type: boolean
          description: |
            Whether any choice applies beyond the app's own default. This is the one
            bit a host page is ever allowed to learn — never *what* was decided.
        widget_token:
          type: string
          description: |
            A single-use handshake token licensing exactly one `POST /v1/consent`
            for this app and this user. Valid 15 minutes.

    LinkRequestCreate:
      type: object
      title: Open a link request
      required: [user_id]
      properties:
        pk:
          type: string
          pattern: "^pk_(test|live)_[A-Za-z0-9]+$"
          description: The app's publishable key, as in `POST /v1/consent`.
        user_id:
          type: string
          minLength: 1
          examples: [usr_marie_456]
        locale:
          type: string
          enum: [en, fr]
          description: |
            The language the widget is being read in, carried into `popup_url`.
            The popup is a top-level window on our own domain, so it inherits
            nothing from the widget's cross-origin iframe — without this it
            opens in whatever the browser's `Accept-Language` asks for, next to
            a widget the embedding page requested in the other language.
            Anything we do not serve is ignored.
          examples: [fr]

    LinkRequestCreated:
      type: object
      title: Link request
      required: [link_request_id, popup_url, expires_at]
      properties:
        link_request_id:
          type: string
          pattern: "^lreq_[0-9a-z]+$"
          examples: [lreq_zyynbzxdl9pcrrzdvuekijj6]
        popup_url:
          type: string
          format: uri
          description: |
            Open this with `window.open()`. It is on our own domain, where we are
            first-party and the session cookie behaves normally — which the
            cross-origin widget iframe cannot rely on.
          examples: [https://api.idside.eu/account/connect/lreq_zyynbzxdl9pcrrzdvuekijj6]
        expires_at:
          type: string
          format: date-time
          description: Ten minutes after creation.

    LinkRequestStatus:
      type: object
      title: Link request state
      required: [status]
      properties:
        status:
          type: string
          enum: [pending, completed, expired]
          description: |
            `pending` — keep polling. `completed` — the user confirmed; the
            resolved preferences are included. `expired` — the ten minutes elapsed;
            open a new request.
        consent_state:
          description: Present only when `status` is `completed`.
          $ref: "#/components/schemas/Preferences"
        source:
          description: Present only when `status` is `completed`.
          $ref: "#/components/schemas/ConsentSource"

    LedgerEntry:
      type: object
      title: Ledger entry
      description: |
        One authorization, as recorded. `previous_hash` and `chain_hash` are what
        make the record tamper-evident: each entry commits to the one before it,
        so rewriting any entry breaks every link after it.
      required:
        [seq, event_id, occurred_at, user_pseudo_id, provider, model, preferences,
         preference_source, legal_basis, params_forced, event_type, binding,
         previous_hash, chain_hash]
      properties:
        seq:
          type: integer
          description: Position in this app's chain, starting at 1. Gaps mean an entry was removed.
        event_id:
          type: string
          pattern: "^evt_[0-9a-z]+$"
        occurred_at:
          type: string
          format: date-time
        user_pseudo_id:
          type: string
          pattern: "^psu_[0-9a-f]{32}$"
          description: The per-app pseudonym of the user. Not reversible, and not comparable across apps.
        provider:
          type: string
          examples: [openai]
        model:
          type: string
          examples: [gpt-4o]
        consent_token_id:
          type: [string, "null"]
          description: The ACT token issued for this authorization.
          examples: [act_01kycmhhx6jw4614ze3gt66n8b]
        preferences:
          $ref: "#/components/schemas/Preferences"
        preference_source:
          $ref: "#/components/schemas/ConsentSource"
        legal_basis:
          $ref: "#/components/schemas/LegalBasis"
        params_forced:
          type: object
          additionalProperties: true
          description: The provider parameters ID Side required for this call.
        constraints:
          type: object
          additionalProperties: true
          description: |
            What the SDK was told to REFUSE for this call, as opposed to what it
            was told to set — today only `{"tools": "refuse"}` or
            `{"tools": "strip"}`.

            It records that agentic tools were refused for this user on this
            call. It does NOT record whether the call actually carried any: the
            constraint follows from the consent state alone, the SDK answers
            that question on its own machine, and ID Side is never told.
        credential_posture:
          type: [string, "null"]
          description: |
            Which of the developer's credentials this call had to go out on — a
            LABEL (`no_training`, `eu_only`…), never a credential. It is the only
            visible sign that two users of one app were treated differently on
            purpose; without it, two entries with identical parameters carry no
            explanation for why one person's traffic went elsewhere.

            `null` when the answer required nothing in particular.
          examples: [no_training]
        credential_provisioned:
          type: [boolean, "null"]
          description: |
            Whether you held a credential for that posture, from the
            `credential_postures` you sent on the authorize call.

            `false` means your SDK raised `MissingCredentialError` and the AI
            call never left your process. That is the most consequential outcome
            the system has, and without this field it is invisible after the
            fact: the authorization succeeded, the token verifies, and the entry
            is indistinguishable from one whose call went through.

            `null` when no posture was required — almost every call.
          examples: [true]
        request_hash:
          type: [string, "null"]
        call_site:
          type: [string, "null"]
          description: |
            The provider endpoint this authorization was for. It is what explains
            `params_forced`: `background: false` can appear on a `responses.create`
            entry and can never appear on a `chat.completions.create` one, because
            the parameter does not exist there. `null` where the SDK was too old to
            say — an honest gap rather than a guessed endpoint.
          examples: [responses.create]
        event_type:
          type: string
          description: |
            `authorize` for an ordinary authorization. `authorize_csh_stale` means
            the call presented a UBT minted against a consent state the user has
            since changed: the token was accepted and the *current* preferences
            were applied, and this entry records that the binding rested on older
            evidence. Silently claiming a fresh binding would tell an auditor
            something untrue.
          examples: [authorize, authorize_csh_stale]
        binding:
          $ref: "#/components/schemas/IdentityBinding"
        previous_hash:
          type: string
          pattern: "^[0-9a-f]{64}$"
          description: The `chain_hash` of the preceding entry, or the app's genesis hash for `seq` 1.
        chain_hash:
          type: string
          pattern: "^[0-9a-f]{64}$"
          description: SHA-256 over `previous_hash` and this entry's canonicalised payload.

    LedgerPage:
      type: object
      title: Ledger page
      required: [entries, head_seq, next_cursor, has_more]
      properties:
        entries:
          type: array
          description: Newest first.
          items:
            $ref: "#/components/schemas/LedgerEntry"
        head_seq:
          type: [integer, "null"]
          description: The highest sequence number in this app's chain, or `null` if it is empty.
        next_cursor:
          type: [integer, "null"]
          description: |
            Pass this back as `before_seq` for the next page. `null` means you
            have reached the oldest entry matching your filters — stop there
            rather than on an empty page, which a filter can also produce.
        has_more:
          type: boolean
          description: Whether entries remain below this page.

    LedgerVerification:
      title: Ledger verification result
      description: |
        Either the chain verified, or it broke and this says where and how.
        A `200` means the check ran — read `intact`.
      oneOf:
        - type: object
          title: Intact
          required: [intact, verified_count, head_hash]
          properties:
            intact:
              const: true
            verified_count:
              type: integer
              description: How many entries were checked.
            head_hash:
              type: string
              pattern: "^[0-9a-f]{64}$"
              description: |
                The `chain_hash` of the last entry verified. Record it; if a later
                verification of the same range returns a different value, the range
                was rewritten.
        - type: object
          title: Broken
          required: [intact, broken_at, reason, verified_count]
          properties:
            intact:
              const: false
            broken_at:
              type: integer
              description: The sequence number at which verification stopped.
            reason:
              type: string
              description: |
                `missing entry` — the sequence has a gap, or it stops short of
                where the chain is recorded as having reached, so an entry was
                removed. `previous_hash mismatch` — this entry does not chain
                onto the one before it. `entry was modified` — the entry's
                contents no longer match the hash committed for it.
                `head hash mismatch` — the chain reaches its recorded end on a
                different hash, i.e. the tail was replaced wholesale.
                `entries past the recorded head` — there are more entries than
                the chain is recorded as having. `no entry before seq N` — the
                requested `from_seq` has nothing to anchor on.
              examples: [entry was modified]
            verified_count:
              type: integer
              description: How many entries verified cleanly before the break.

    Jwks:
      type: object
      title: JWK Set
      required: [keys]
      properties:
        keys:
          type: array
          items:
            type: object
            required: [kty, crv, x, y, kid, use, alg]
            properties:
              kty:
                const: EC
              crv:
                const: P-256
              x:
                type: string
                description: Base64url-encoded x coordinate.
              y:
                type: string
                description: Base64url-encoded y coordinate.
              kid:
                type: string
                description: |
                  Key identifier, matching the `kid` in a token header. Named for its
                  purpose and generation, for example `act-2026q3` for ACT proofs and
                  `ubt-2026q3` for user bindings. Keys issued outside production carry
                  a `-dev` suffix, so a development token can never be mistaken for a
                  real one.
                examples: [act-2026q3]
              use:
                const: sig
              alg:
                const: ES256
