openapi: '3.1.0'
info:
  title: Unforged Platform API
  version: '2026-09-27'
  summary: The Unforged platform. Organisations, API keys, the audit log, billing and operations.
  description: |
    The platform routes of the Unforged API, at the root: the caller (`/me`), API keys
    (`/keys`), the audit log (`/audit`), organisation settings (`/orgs/current/…`), billing
    and the operational routes (`/health`, `/ready`, `/version`, `/metrics`). The Unforged Nib
    product routes live under `/nib/` and are described in `nib.yaml`.

    **Authentication.** `Authorization: Bearer <API key>` on every authenticated route. Client
    session tokens (`unf_ses_…`) are accepted only on `GET /nib/live` and on a streamed
    `POST /nib/checks` (`Accept: text/event-stream`). An API key is `unf_<env>_<opaque>`: `live`
    keys in production, then a fixed-length base62 part that carries a version, a region hint,
    the key's id and its secret. Treat the whole key as a secret. Keys are shown by their prefix
    only (`unf_live_01AbCdEf…`): the prefix is for recognising a key, never for looking one up.

    **Regions.** Your organisation's data lives in its home region, chosen when it is created
    and fixed afterwards (`GET /me` → `tenant.home_region`). Call `https://api.unforged.sh`,
    which routes each request by its key, or your region's host
    `https://<region>.api.unforged.sh` directly. A regional host answers only for the
    organisations that live there: any other caller gets 421 `wrong_region`, naming the
    `region` and its `api_host`. A key keeps working when Unforged moves your organisation
    to another region. While a move runs, every request
    for the organisation answers 503 `org_moving` with `Retry-After`.

    **Access.** Every route requires exactly one permission, named on its operation as
    `x-permission` (`none`: an unauthenticated operational route, or one that verifies its own
    requests). An API key carries an explicit set of permissions, chosen when it is created and
    never more than its creator holds; a dashboard user has the permissions of their role. A
    caller without the route's permission gets 403 `permission_denied` ("requires
    <permission>"). The catalogue, the default roles and the route table are in
    `docs/api/permissions.md` (generated; also on https://unforged.ai/security#access).

    **Dashboard users.** The Unforged dashboard calls the same routes with the signed-in
    user's WorkOS access token (`Authorization: Bearer <JWT>`, RS256, at most 8192 bytes),
    verified against WorkOS's signing keys: issuer, audience (or `client_id`), expiry. The
    token's organisation (`org_id`) must be provisioned as a tenant, else 403
    `tenant_not_provisioned`. The token's `permissions` (from the user's role) set what the
    user may do. Changes made by a user are audited as `user:<user id>`. Routes for dashboard
    users only (billing, the team, and changes to organisation settings) refuse API keys (403
    `permission_denied`, "dashboard users only"). Routes marked `x-internal` serve the web app.

    **Tenancy.** Every id is scoped to your tenant. Another tenant's resource is answered
    exactly like a missing one: 404 `not_found`, never 403.

    **Organisations.** An organisation may have child organisations (at most 3 levels: a root
    and two below it), created with `POST /orgs/children` and billed through the parent. A
    caller with `org:children:access` acts in a descendant by sending `Unforged-Org: <org id>`
    on any authenticated request: the request then runs in that organisation (one
    organisation per request) with the caller's permissions minus `org:keys:manage`,
    `org:billing:manage` and `org:children:manage`, and is audited there as `org.act_as`.
    Anything else (another tree, a sibling, the parent from a child, an unknown or suspended
    organisation, or no permission) is the same 403 `permission_denied`. Bucket keys cannot be
    created or deleted while acting. An organisation that signs in with SSO may act only inside
    its own subtree. Suspending an organisation suspends everything below it: their API keys and
    users get 403 `org_suspended`; a parent may reactivate only a child it suspended itself.
    `GET /me/orgs` lists the organisations the signed-in user can switch to.

    **Errors** are RFC 9457 `application/problem+json` documents with a stable `code`
    (see the `Problem` schema). **Every response** carries `X-Request-Id`: yours, when you
    send one of 1–128 `[A-Za-z0-9._-]`, else a generated `req_<ulid>`.

    **Idempotency.** Every POST needs an `Idempotency-Key` (1–255 visible ASCII characters)
    except the ones that return a secret shown once (`/nib/sessions`, `/keys`,
    `/nib/bucket-keys`, `/nib/webhooks`). Keys are kept 24 h per tenant. The same key with the
    same body replays the original response (`Idempotent-Replayed: true`); the same key with a
    different body is 409 `idempotency_conflict`; while the first request runs, 409
    `idempotency_in_progress`. Only successful responses (and the committed outcomes of checks)
    are replayed; an error releases the key.

    **Billing.** Self-serve tenants pay $0.50 per billable check item (a decision of
    `AUTO_APPROVE`, `APPROVE`, `FLAG` or `FAILED`; `UNREADABLE`, `NO_SIGNATURE`, item errors
    and enrolment are free), metered through Stripe. Until the tenant's subscription is
    active (or while it is canceled), checks, client sessions, live capture and new API or
    bucket keys answer 402 `billing_required`; enrolment, reads and settings stay open, and
    a bucket drop gets a rejected `summary.json` with a `billing_required` error.
    Organisations on a contract (unlimited, or a committed spend drawn down check by check,
    with overage on the next invoice) are never refused while the contract is in date or in
    its grace period; once it has lapsed they are refused like a tenant without a card. The
    plan is shown by `GET /billing/plan`.

    **Limits.** A per-tenant token bucket (default 20 requests/s, burst 50) answers 429
    `rate_limited` with `Retry-After` (seconds). Monthly check-item quotas answer 429
    `quota_exceeded` with `Retry-After` until the next UTC month; a bucket drop over the
    quota gets a rejected `summary.json` with a `quota_exceeded` error instead (see
    `Manifest`). Bodies are at most 51 MB (a document of up to 50 MB plus
    the request's multipart framing)
    (413 `too_large`); a request must arrive within 120 s (408 `timeout`).
    **Versioning.** There is no version in any URL. The API version is a date, sent as the
    request header `Unforged-Version: YYYY-MM-DD`. Your organisation is pinned to the version
    that was current when it was created; a request without the header uses that pin, and
    unauthenticated routes use the latest version. The header overrides the pin
    for one request (it never changes the pin), so you can test a newer version before you
    upgrade the pin in the dashboard (you can roll back within 72 hours). An unknown or
    malformed version, or one past its sunset, is 400 `invalid_version`. Every response carries
    `Unforged-Version` (the version used) and `Unforged-Latest-Version`.

    Additive changes never create a version: new endpoints, new optional parameters, new
    response fields and new values of documented open enums reach every version. **Clients
    must ignore unknown fields.** A breaking change (removing or renaming a field or endpoint,
    changing a field's type, meaning, default or status code, making a parameter required,
    adding a value to a closed enum, or changing a webhook payload's shape) ships only in a new
    dated version, with a changelog entry. Webhooks are rendered in your pinned version.

    A version stays supported for 24 months after a newer one replaces it. A request on a
    deprecated version (or to a deprecated endpoint) still succeeds, and its response also
    carries `Deprecation: @<unix seconds>` (RFC 9745), `Sunset: <HTTP date>` (RFC 8594) and
    `Link: <https://unforged.ai/nib/docs/changelog#<date>>; rel="deprecation",
    <https://unforged.ai/nib/docs/upgrading#<date>>; rel="sunset"`.
  contact:
    name: Unforged
    url: https://unforged.sh
servers:
  - url: https://api.unforged.sh
security:
  - apiKey: []
  - workosJwt: []
tags:
  - name: admin
    description: API keys (`org:keys:manage`) and the audit log (`org:audit:read`).
  - name: orgs
    description: Organisation settings (dashboard users), such as the API version pin.
  - name: operations
    description: Health, readiness, versions and metrics (no API key).
  - name: web
    description: The Unforged web app (the caller, tenant provisioning, the contact form).
  - name: billing
    description: Stripe Checkout, the customer portal, tenant plans and Stripe's webhook.
  - name: children
    description: >-
      Child organisations (`org:children:access` to list and act in them,
      `org:children:manage` to create and suspend them; dashboard users only).
  - name: team
    description: >-
      The organisation's members and roles (`org:read`) and the WorkOS Admin
      Portal links for SSO, directory sync and role mapping (`org:sso:manage`);
      dashboard users only.
paths:
  # -------------------------------------------------------------------------
  # admin: API keys
  /keys:
    get:
      tags: [admin]
      operationId: listApiKeys
      x-permission: org:keys:manage
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
      summary: API keys (never a key or its hash)
      responses:
        '200':
          description: The keys.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema:
                type: object
                required: [api_keys]
                properties:
                  api_keys:
                    type: array
                    items: { $ref: '#/components/schemas/ApiKey' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }
    post:
      tags: [admin]
      operationId: createApiKey
      x-permission: org:keys:manage
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
      summary: Issue an API key
      description: |
        The key is shown **once** (`Cache-Control: no-store`); only its display prefix, its id
        and the hash of its secret are stored. It carries the organisation's home region as a
        routing hint (not a secret: the secret part is). No `Idempotency-Key`: a retry issues
        another key. Audited.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ApiKeyCreate' }
      responses:
        '201':
          description: Created.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
            Cache-Control: { $ref: '#/components/headers/NoStore' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiKeyCreated' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  /keys/{key_id}:
    delete:
      tags: [admin]
      operationId: revokeApiKey
      x-permission: org:keys:manage
      summary: Revoke an API key
      description: |
        204 also when already revoked. As for creation, the key's permissions must be within the
        caller's own (else 403 `permission_denied` naming the excess; an owner holds every
        permission). An API-key caller cannot revoke the last active key holding
        `org:keys:manage` (409).
        Takes effect on the next request. Audited.
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/KeyId'
      responses:
        '204': { $ref: '#/components/responses/NoContent' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  # -------------------------------------------------------------------------
  # admin: audit log
  /audit:
    get:
      tags: [admin]
      operationId: getAudit
      x-permission: org:audit:read
      summary: The audit log, newest first
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - name: cursor
          in: query
          schema: { type: string }
          description: The previous page's `next_cursor`.
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
        - name: action
          in: query
          schema: { type: string }
          description: An exact action, or a prefix ending in `*` (`api_key.*`).
      responses:
        '200':
          description: A page of entries.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuditPage' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  # -------------------------------------------------------------------------
  # the web app (x-internal)
  /me:
    get:
      tags: [web]
      operationId: getMe
      x-permission: org:read
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
      summary: The caller and its tenant
      x-internal: true
      description: |
        For API keys and dashboard users: the tenant and who is calling, with the caller's role
        (dashboard users) and effective permissions, the tenant's billing
        (`stripe`, `invoiced` or `parent`, the subscription's status, the plan's type and a
        contract's state, and the sign-up credit), whether SSO is enabled, and
        the organisation's API version (`pinned`, `latest`, and the pinned version's
        `deprecated_at` and `sunset` when it is deprecated).
      responses:
        '200':
          description: The caller.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Me' }
              example:
                tenant: { id: ten_01j9x3m8v7k2q4r5s6t7u8v9w0, slug: acme-bank-x7k2, name: Acme Bank, region: eu-west-2, home_region: uk-london }
                caller: { kind: user, id: user_01J9X3M8V7K2Q4R5S6T7U8V9W0, role: viewer, permissions: ['org:read', 'nib:checks:read', 'nib:subjects:read'] }
                billing: { mode: stripe, status: pending, plan_type: payg, plan_state: null, billed_through: null, promo_credit: { granted_usd: '10.00', remaining_usd: '7.50', converted: false, applies: true } }
                sso_enabled: false
                sso_required: false
                api_version: { pinned: '2026-09-27', latest: '2026-09-27', deprecated_at: null, sunset: null }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
        default: { $ref: '#/components/responses/Internal' }

  /internal/tenants:
    post:
      tags: [web]
      operationId: provisionTenant
      x-permission: none
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
      summary: Provision the tenant of a WorkOS organisation
      x-internal: true
      description: |
        The web server's call at sign-up, with its service token (`NIB_INTERNAL_TOKEN`). Without
        the right token the route does not exist: every method answers 404 `not_found`, exactly
        like an unknown route. Idempotent on `org_id`: a new organisation gets a tenant (slug
        `<name as a slug>-<4 random>`, the home region `region`, the default policy; audited as
        `internal:web`) and 201; an organisation already mapped answers 200 with its tenant and
        `created: false`. `region` must be a live region (else 400 `invalid_request`, "region
        <id> is not available") served by this host (else 421 `wrong_region`: call that
        region's host). The home region is fixed at creation. A new organisation gets the $10.00
        sign-up credit; the optional `email` (the signing-up user's; ignored when it is not an
        email address) names its Stripe customer, created in the background with the credit's
        grant.
        No `Idempotency-Key`, no rate limit.
      security:
        - internalToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TenantProvision' }
            example: { org_id: org_01J9X3M8V7K2Q4R5S6T7U8V9W0, name: Acme Bank, region: uk-london, email: ada@acme-bank.example }
      responses:
        '201':
          description: A new tenant.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantProvisioned' }
              example: { tenant_id: ten_01j9x3m8v7k2q4r5s6t7u8v9w0, slug: acme-bank-x7k2, region: uk-london, created: true }
        '200':
          description: The organisation already has a tenant.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantProvisioned' }
              example: { tenant_id: ten_01j9x3m8v7k2q4r5s6t7u8v9w0, slug: acme-bank-x7k2, region: uk-london, created: false }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
        default: { $ref: '#/components/responses/Internal' }

  /contact:
    post:
      tags: [web]
      operationId: contact
      x-permission: none
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
      summary: The website's contact form
      x-internal: true
      description: |
        "Talk to us". No credential, no `Idempotency-Key`. A JSON body of at most 16 KB. A
        non-empty `website` (a honeypot) is answered 202 and dropped. At most 5 requests per
        client address per hour (429 `rate_limited`, `Retry-After: 3600`); the address is
        stored only as a salted SHA-256.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ContactRequest' }
            example:
              name: Ada Lovelace
              email: ada@example.com
              company: Analytical Engines
              topic: volume
              message: We check about a million signatures a month.
              website: ''
      responses:
        '202':
          description: Received.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema:
                type: object
                required: [received]
                properties:
                  received: { type: boolean, const: true }
              example: { received: true }
        '400': { $ref: '#/components/responses/BadRequest' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  # -------------------------------------------------------------------------
  # billing (x-internal)
  /billing/checkout:
    post:
      tags: [billing]
      operationId: createBillingCheckout
      x-permission: org:billing:manage
      summary: Start a Stripe Checkout for the metered subscription
      x-internal: true
      description: |
        Dashboard users only (an API key gets 403 `permission_denied`). Creates the
        tenant's Stripe customer on first use, then a subscription Checkout session for the
        metered price ($0.50 per billable check). Send the user to `url`; Stripe returns them to
        `/org/billing?checkout=success` (or `cancel`) on the web app, and the subscription becomes
        active through Stripe's webhook. An invoiced tenant gets 409 `conflict`. A tenant whose
        billing is active or past due, or that still has a live subscription, gets 409
        `billing_already_active`: use the portal (one subscription per tenant).
        A tenant has one open Checkout session: while it has time left, every call returns
        its URL again; an old session is expired before a new one is made. A deployment
        without Stripe answers 501 `not_available_in_dev`. The body, if any, must be `{}`.
        An organisation whose contract has lapsed may check out to go back to pay as you go;
        when it still has a live subscription on one of its pay-as-you-go prices, the request
        itself puts it back on that plan and answers 200 `{url: null, already_active: true,
        plan_type}` (no Checkout is needed).
      security:
        - workosJwt: []
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: false }
            example: {}
      responses:
        '200':
          description: A lapsed contract revived on its live subscription (no Checkout).
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema:
                type: object
                required: [url, already_active, plan_type]
                properties:
                  url: { type: 'null' }
                  already_active: { type: boolean, const: true }
                  plan_type: { type: string, enum: [payg, payg_discounted] }
              example: { url: null, already_active: true, plan_type: payg }
        '201':
          description: The Checkout page.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BillingUrl' }
              example: { url: 'https://checkout.stripe.com/c/pay/cs_test_a1b2c3' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '501': { $ref: '#/components/responses/NotImplemented' }
        '503': { $ref: '#/components/responses/Unavailable' }
        default: { $ref: '#/components/responses/Internal' }

  /billing/portal:
    post:
      tags: [billing]
      operationId: createBillingPortal
      x-permission: org:billing:manage
      summary: Open the Stripe customer portal
      x-internal: true
      description: |
        Dashboard users only (an API key gets 403 `permission_denied`). Payment
        methods, invoices and cancellation; Stripe returns the user to `/billing`. 409
        `conflict` while the tenant has no Stripe customer (no Checkout yet); 501
        `not_available_in_dev` without Stripe. The body, if any, must be `{}`.
      security:
        - workosJwt: []
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: false }
            example: {}
      responses:
        '201':
          description: The portal page.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BillingUrl' }
              example: { url: 'https://billing.stripe.com/p/session/test_YWNjdF8x' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '501': { $ref: '#/components/responses/NotImplemented' }
        '503': { $ref: '#/components/responses/Unavailable' }
        default: { $ref: '#/components/responses/Internal' }

  /billing/plan:
    get:
      tags: [billing]
      operationId: getBillingPlan
      x-permission: org:read
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
      summary: The organisation's billing plan (read-only)
      x-internal: true
      description: |
        Dashboard users only (an API key gets 403 `permission_denied`). The plan in effect
        today: its type (`payg`, `payg_discounted`, `contract_unlimited`, `contract_commit`,
        `parent`), a contract's `state` (`in_date`, `in_grace` after `ends_on` until
        `grace_ends_on`, then `lapsed`; null for other types), its terms, the credit left on a
        committed contract (`{checks}` or `{usd}`) and this UTC month's billable checks and
        overage. Plans are set by Unforged staff; the contract's value is never shown. A
        sub-organisation billed through its parent sees `type: parent` and the parent's
        state only. While a contract is in date or in grace, checks are never refused for
        billing; once lapsed, the gated routes answer 402 `billing_required` until a new
        plan starts or a Checkout completes.
      responses:
        '200':
          description: The plan.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BillingPlanView' }
              example:
                type: contract_commit
                state: in_date
                starts_on: '2026-10-01'
                ends_on: '2027-09-30'
                grace_ends_on: '2027-10-30'
                external_ref: Q-2026-0042
                unit_price_usd: null
                overage_unit_price_usd: '0.4500'
                included_checks: 120000
                credit_remaining: { checks: 118250 }
                fair_use_monthly_checks: null
                this_month: { billable: 1750, overage: 0 }
                promo_credit: null
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
        default: { $ref: '#/components/responses/Internal' }

  /billing/credits:
    get:
      tags: [billing]
      operationId: getBillingCredits
      x-permission: org:read
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
      summary: Every credit the organisation has left (read-only)
      x-internal: true
      description: |
        Dashboard users only (an API key gets 403 `permission_denied`). The credits the
        organisation has left, one item each, with a `kind`; empty ones are left out:
        `signup`, the sign-up credit while it applies (plain pay as you go, no card added yet);
        `contract`, a committed contract's remaining balance (`remaining_checks` for an
        allowance, `remaining_usd` for money, rounded down to the cent); `stripe`, the credit on
        the organisation's Stripe account once a card is added (a converted sign-up credit's
        balance, or credit granted by Unforged), applied to the next invoices. The Stripe
        balance is re-read at most once a minute; while it cannot be read it is left out. A
        sub-organisation billed through its parent has none of its own: `items` is empty.
      responses:
        '200':
          description: The credits.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BillingCredits' }
              example:
                items:
                  - { kind: contract, remaining_checks: 118250 }
                  - { kind: stripe, remaining_usd: '6.50' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
        default: { $ref: '#/components/responses/Internal' }

  /internal/tenants/{tenant_id}/plan:
    put:
      tags: [billing]
      operationId: setTenantPlan
      x-permission: none
      summary: Set a tenant's billing plan
      x-internal: true
      description: |
        Staff only: the web server's (later the management plane's) call with its service token
        (`NIB_INTERNAL_TOKEN`); without it every method answers 404 `not_found`, like an
        unknown route. Adds a plan version (`payg`, `payg_discounted`, `contract_unlimited`,
        `contract_commit` or `parent`), validated by the rules of its type: 400
        `invalid_request` naming the field (`plan.<field>: …`). Money is a decimal string (at
        most 4 decimal places for prices, 2 for amounts); dates are `YYYY-MM-DD`; `starts_on`
        before today needs `?backdate=true` (audited as backdated); `meter_mode: included`
        needs the included-checks price configured. The latest version whose `starts_on` has
        come is in effect. The billing mode follows the plan (`payg*` → `stripe`, contracts →
        `invoiced`, `parent` → `parent`). Every change appends an append-only history row
        (source `internal_api`, or `quote` with the `quote_id`) and an audit row
        `billing.plan.set`, in one transaction. `sso_enabled` and `sso_required` (an override
        of the SSO ringfence flag, normally synced from WorkOS) are optional. While the tenant
        has a live subscription, a pay-as-you-go plan must match its price (`payg`: the
        standard price; `payg_discounted`: its `stripe_price_id`) and `meter_mode: included`
        is refused (400 naming the field: swap or cancel the subscription in Stripe first);
        409 `conflict` when the subscription changed while the plan was checked. The audit
        entry shows the actor as `staff` and leaves the contract value out; the plan history
        keeps both. 503 `busy` while the organisation moves. Applies to the tenant's next
        request (up to 15 s on other API instances).
      security:
        - internalToken: []
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/TenantId'
        - name: backdate
          in: query
          required: false
          description: Allow `starts_on` before today.
          schema: { type: boolean, default: false }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TenantPlan' }
            example:
              quote_id: Q-2026-0042
              plan:
                type: contract_commit
                starts_on: '2026-10-01'
                ends_on: '2027-09-30'
                contract_value_usd: '54000.00'
                committed_amount_usd: '54000.00'
                included_checks: 120000
                overage_unit_price_usd: '0.4500'
                external_ref: Q-2026-0042
      responses:
        '200':
          description: The new plan version, the plan in effect today and the history row.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantPlanned' }
              example:
                tenant_id: ten_01j9x3m8v7k2q4r5s6t7u8v9w0
                plan: { id: 42, type: contract_commit, starts_on: '2026-10-01', ends_on: '2027-09-30', grace_days: 30, contract_value_usd: '54000.00', committed_amount_usd: '54000.00', included_checks: 120000, overage_unit_price_usd: '0.4500', external_ref: Q-2026-0042 }
                effective: { id: 7, type: payg, starts_on: '2026-09-27', grace_days: 30 }
                history_id: 57
                billing: { mode: stripe, status: active }
                sso_enabled: false
                sso_required: false
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '503': { $ref: '#/components/responses/Unavailable' }
        default: { $ref: '#/components/responses/Internal' }

  /stripe/webhook:
    post:
      tags: [billing]
      operationId: stripeWebhook
      x-permission: none
      summary: Stripe's webhook
      x-internal: true
      description: |
        Called by Stripe only. No credential and no `Idempotency-Key`: the raw body (at most
        1 MB) must carry a valid `Stripe-Signature` (HMAC-SHA256 with the endpoint's signing
        secret over `<t>.<body>`, any matching `v1`, `t` within 5 minutes), else 400
        `invalid_signature`. Events are processed once per event id: a redelivery answers
        `duplicate: true`. Handled: `checkout.session.completed` (only for a subscription
        Checkout on the configured price by a customer already bound to the referenced tenant;
        binds the subscription and activates the tenant) and `customer.subscription.created`, `updated` and
        `deleted` (the billing status, applied in event order, only for the tenant's own
        subscription; an invoiced tenant's status is never changed); other events are
        acknowledged.
        501 `not_available_in_dev` when no signing secret is configured.
      security: []
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - name: Stripe-Signature
          in: header
          required: true
          description: '`t=<unix seconds>,v1=<hex HMAC-SHA256>[,v1=…]`'
          schema: { type: string }
          example: t=1727400000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/StripeEvent' }
            example:
              id: evt_1NG8Du2eZvKYlo2CUI79vXWy
              object: event
              type: customer.subscription.updated
              created: 1727400000
              data: { object: { id: sub_1MowQVLkdIwHu7ixeRlqHVzs, customer: cus_NffrFeUfNV2Hib, status: active } }
      responses:
        '200':
          description: Received (processed now, or already).
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema:
                type: object
                required: [received]
                properties:
                  received: { type: boolean, const: true }
                  duplicate: { type: boolean, const: true }
              example: { received: true }
        '400': { $ref: '#/components/responses/BadRequest' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '501': { $ref: '#/components/responses/NotImplemented' }
        default: { $ref: '#/components/responses/Internal' }

  # -------------------------------------------------------------------------
  # organisation settings (dashboard users)
  /orgs/current/api-version:
    get:
      tags: [orgs]
      operationId: getApiVersion
      x-permission: org:read
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
      summary: The organisation's API version pin
      x-internal: true
      description: |
        Any caller with `org:read`: dashboard users and API keys.
      responses:
        '200':
          description: The pin, the latest version and every published version.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiVersionState' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }
    put:
      tags: [orgs]
      operationId: upgradeApiVersion
      x-permission: org:settings:manage
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
      summary: Upgrade the pin
      x-internal: true
      description: |
        Dashboard users only (an API key gets 403). The version must be published, newer than the pin and not past
        its sunset (else 400 `invalid_request`). Audited `org.api_version.upgrade`. The
        previous pin can be restored for 72 hours.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ApiVersionUpgrade' }
      responses:
        '200':
          description: The new state.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiVersionState' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }
  /orgs/current/api-version/rollback:
    post:
      tags: [orgs]
      operationId: rollbackApiVersion
      x-permission: org:settings:manage
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/IdempotencyKey'
      summary: Roll the pin back
      x-internal: true
      description: |
        Dashboard users only (an API key gets 403). Back to the previous pin within 72 hours of the last change;
        409 `conflict` otherwise ("the rollback window has closed"). Audited
        `org.api_version.rollback`. The body is empty or `{}`.
      responses:
        '200':
          description: The new state.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiVersionState' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  # -------------------------------------------------------------------------
  # multi-org and child organisations (dashboard users)
  /me/orgs:
    get:
      tags: [web]
      operationId: listMyOrgs
      x-permission: org:read
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
      summary: The organisations the signed-in user can switch to
      x-internal: true
      description: |
        Dashboard users only (an API key gets 403). The user's active memberships whose
        organisation is provisioned (`via: membership`, with the membership's role) and, with
        `org:children:access`, the active descendants of the token's organisation (`via:
        parent`, reached by acting with `Unforged-Org`). When the token's organisation requires
        SSO, only that organisation and its descendants are listed. A request made as one
        organisation never reads another's data, whatever the user's other memberships. 501
        `not_available_in_dev` when WorkOS is not configured.
      responses:
        '200':
          description: The organisations.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MyOrgs' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '501': { $ref: '#/components/responses/NotImplemented' }
        '503': { $ref: '#/components/responses/Unavailable' }
        default: { $ref: '#/components/responses/Internal' }
  /orgs/children:
    get:
      tags: [children]
      operationId: listChildOrgs
      x-permission: org:children:access
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
      summary: The direct child organisations
      x-internal: true
      description: |
        Dashboard users only. The direct children of the organisation (any status), oldest
        first, with their status, billing mode and whether they require SSO.
      responses:
        '200':
          description: The children.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ChildOrgList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }
    post:
      tags: [children]
      operationId: createChildOrg
      x-permission: org:children:manage
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/IdempotencyKey'
      summary: Create a child organisation
      x-internal: true
      description: |
        Dashboard users only. Creates the WorkOS organisation and its tenant as a child of the
        caller's organisation, billed through the parent and pinned to the parent's API
        version. No member is added: the parent's users reach it with `Unforged-Org`. 409
        `conflict` when the caller's organisation is already 3 levels deep. `region` defaults to
        the parent's home region; like provisioning, it must be live (400) and served here
        (421). Audited in the parent (`org.child.create`) and in the child
        (`tenant.provision`).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ChildOrgCreate' }
      responses:
        '201':
          description: The new organisation.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ChildOrgCreated' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '501': { $ref: '#/components/responses/NotImplemented' }
        '503': { $ref: '#/components/responses/Unavailable' }
        default: { $ref: '#/components/responses/Internal' }
  /orgs/children/{org_id}/status:
    put:
      tags: [children]
      operationId: setChildOrgStatus
      x-permission: org:children:manage
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/OrgId'
      summary: Suspend or reactivate a child organisation
      x-internal: true
      description: |
        Dashboard users only, for a direct child (anything else is 403, the same answer as a
        refused `Unforged-Org`). A suspension cascades: the organisation's and its descendants' own
        API keys and users get 403 `org_suspended`, and acting in them is refused. A parent may
        reactivate only a child it suspended itself (409 `conflict` for one Unforged suspended).
        Audited in the parent and the child (`org.child.status`).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ChildOrgStatus' }
      responses:
        '200':
          description: The child.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ChildOrg' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  # -------------------------------------------------------------------------
  # the team (dashboard users only)
  /team/members:
    get:
      tags: [team]
      operationId: listTeamMembers
      x-permission: org:read
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
      summary: The organisation's members
      x-internal: true
      description: |
        Dashboard users only. The organisation's WorkOS memberships (the first 100, every
        status) with each member's email, name and role slug; reused for up to 60 seconds.
        Invitations and role changes are made in the identity provider or WorkOS. 501
        `not_available_in_dev` where WorkOS is not configured; 503 `busy` while it is
        unavailable.
      responses:
        '200':
          description: The members.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TeamMembers' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '501': { $ref: '#/components/responses/NotImplemented' }
        '503': { $ref: '#/components/responses/Unavailable' }
        default: { $ref: '#/components/responses/Internal' }
  /team/roles:
    get:
      tags: [team]
      operationId: listTeamRoles
      x-permission: org:read
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
      summary: The organisation's roles and their permissions
      x-internal: true
      description: |
        Dashboard users only. The six default roles, then the organisation's other roles from
        WorkOS (its custom roles), marked `custom`, with the permissions of the catalogue they
        hold. Where WorkOS does not list an organisation's roles, the unknown role slugs of its
        members are listed as custom with no permissions (`custom_source: members`).
      responses:
        '200':
          description: The roles.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TeamRoles' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '501': { $ref: '#/components/responses/NotImplemented' }
        '503': { $ref: '#/components/responses/Unavailable' }
        default: { $ref: '#/components/responses/Internal' }
  /team/admin-portal:
    post:
      tags: [team]
      operationId: createAdminPortalLink
      x-permission: org:sso:manage
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/IdempotencyKey'
      summary: A WorkOS Admin Portal link (SSO, or directory sync and role mapping)
      x-internal: true
      description: |
        Dashboard users only. A short-lived link to the WorkOS Admin Portal for the
        organisation: `sso` to connect an identity provider, `dsync` to connect a directory.
        Mapping identity-provider groups to roles is part of both flows. The portal returns to
        the dashboard's team page. 403 `permission_denied` ("SSO set-up is on Talk to us
        plans") for an organisation without SSO on its plan. Audited (`team.admin_portal`).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AdminPortalRequest' }
      responses:
        '201':
          description: The link.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AdminPortalLink' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '501': { $ref: '#/components/responses/NotImplemented' }
        '503': { $ref: '#/components/responses/Unavailable' }
        default: { $ref: '#/components/responses/Internal' }

  # -------------------------------------------------------------------------
  # operations (no API key)
  /health:
    get:
      tags: [operations]
      operationId: health
      x-permission: none
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
      summary: Liveness
      security: []
      responses:
        '200':
          description: The process is serving requests.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status: { type: string, const: ok }
              example: { status: ok }

  /ready:
    get:
      tags: [operations]
      operationId: ready
      x-permission: none
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
      summary: Readiness
      security: []
      responses:
        '200':
          description: Ready.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ready' }
        '503':
          description: Not ready (problem `not_ready`).
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

  /version:
    get:
      tags: [operations]
      operationId: version
      x-permission: none
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
      summary: The model version and the latest API version
      security: []
      responses:
        '200':
          description: The versions.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Version' }

  /metrics:
    get:
      tags: [operations]
      operationId: metrics
      x-permission: none
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
      summary: Prometheus metrics (operators only)
      description: |
        Exists only when the operator configured a metrics token (`UNF_METRICS_TOKEN`); else
        404 exactly like an unknown route. The token (not an API key) is required as a bearer
        token (401 otherwise).
      security: []
      responses:
        '200':
          description: Prometheus text exposition.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            text/plain:
              schema: { type: string }
        '401':
          description: The metrics token is missing or wrong.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '404':
          description: No metrics token is configured.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: unf_<env>_<opaque>
      description: A tenant API key holding the route's permission (`x-permission`). Its opaque part carries its organisation's region, so `https://api.unforged.sh` routes it without a lookup.
    sessionToken:
      type: http
      scheme: bearer
      bearerFormat: unf_ses_<token>
      description: |
        A client session token (`POST /nib/sessions`): only `GET /nib/live` and a streamed
        `POST /nib/checks`. A session token of a suspended organisation (or of one below a
        suspended organisation) is 401, like an expired or revoked one: session tokens are
        held by end users, so they never learn the organisation's state.
    workosJwt:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        A dashboard user's WorkOS access token (RS256, at most 8192 bytes). Its `org_id` must be
        a provisioned tenant; its `permissions` (from the user's `role`) set what the user may do.
    internalToken:
      type: http
      scheme: bearer
      description: The web server's service token (`NIB_INTERNAL_TOKEN`), for `/internal/*` only.
    sessionSubprotocol:
      type: apiKey
      in: header
      name: Sec-WebSocket-Protocol
      description: |
        Browsers' form of a session token on `GET /nib/live` only, when there is no
        `Authorization` header: `unforged.session, <session token>`. The server selects
        `unforged.session`.

  headers:
    RequestId:
      description: The request id (yours when valid, else `req_<ulid>`); also in problem bodies.
      schema: { type: string, maxLength: 128 }
      example: req_01j9x3m8v7k2q4r5s6t7u8v9w0
    RetryAfter:
      description: Seconds to wait before retrying.
      schema: { type: integer, minimum: 0 }
      example: 1
    IdempotentReplayed:
      description: '`true` on a replayed response of an earlier request with the same key.'
      schema: { type: string, const: 'true' }
    NoStore:
      description: The body holds a secret shown once.
      schema: { type: string, const: no-store }
    WwwAuthenticate:
      schema: { type: string, const: Bearer }
    UnforgedVersion:
      description: |
        The API version this response was produced in: the request's `Unforged-Version` header
        when sent, else the organisation's pinned version (the latest version on
        unauthenticated routes).
      schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
      example: '2026-09-27'
    UnforgedLatestVersion:
      description: The latest API version.
      schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
      example: '2026-09-27'

  parameters:
    UnforgedVersion:
      name: Unforged-Version
      in: header
      required: false
      description: |
        The API version (`YYYY-MM-DD`) to use for this request, overriding the organisation's
        pinned version (it never changes the pin). An unknown or malformed version, or one past
        its sunset, is 400 `invalid_version`.
      schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
      example: '2026-09-27'
    UnforgedOrg:
      name: Unforged-Org
      in: header
      required: false
      description: |
        Act in a descendant organisation: its WorkOS organisation id (`^[A-Za-z0-9_]{1,64}$`,
        else 400 `invalid_request`). Needs `org:children:access`, and the organisation must be
        an active descendant of the caller's (and inside the caller's subtree when the caller's
        organisation requires SSO); otherwise 403 `permission_denied`, the same answer whether or
        not the organisation exists. The request then runs in the descendant with the caller's
        permissions minus `org:keys:manage`, `org:billing:manage` and `org:children:manage`, in
        the caller's API version. Every such request is audited in the descendant
        (`org.act_as`, with the caller's organisation as `actor_org_id`). Your own organisation's
        id is a no-op.
      schema: { type: string, pattern: '^[A-Za-z0-9_]{1,64}$' }
      example: org_01j9x3m8v7k2q4r5s6t7u8v9w0
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        1–255 visible ASCII characters, kept 24 h per tenant. On `POST /nib/checks` it is also
        the job id when the request names none.
      schema: { type: string, minLength: 1, maxLength: 255 }
      example: inv-2291
    TenantId:
      name: tenant_id
      in: path
      required: true
      description: A tenant id (`ten_…`).
      schema: { type: string }
      example: ten_01j9x3m8v7k2q4r5s6t7u8v9w0
    OrgId:
      name: org_id
      in: path
      required: true
      description: A WorkOS organisation id.
      schema: { type: string, pattern: '^[A-Za-z0-9_]{1,64}$' }
      example: org_01j9x3m8v7k2q4r5s6t7u8v9w0
    KeyId:
      name: key_id
      in: path
      required: true
      schema: { type: string, pattern: '^key_[0-9a-z]{26}$' }
      example: key_01j9x3m8v7k2q4r5s6t7u8v9w0

  responses:
    NoContent:
      description: Done.
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
    BadRequest:
      description: '`invalid_request`, `invalid_version`, `idempotency_key_required` or (Stripe''s webhook) `invalid_signature`.'
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://unforged.sh/problems/invalid_request
            title: Invalid request
            status: 400
            detail: subject_reference must be a valid id ([A-Za-z0-9._-]{1,128})
            code: invalid_request
            request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
    Unauthorized:
      description: '`unauthorized`: no, an invalid, a revoked or an expired credential.'
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
        WWW-Authenticate: { $ref: '#/components/headers/WwwAuthenticate' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://unforged.sh/problems/unauthorized
            title: Unauthorized
            status: 401
            detail: invalid or revoked API key
            code: unauthorized
            request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
    Forbidden:
      description: '`permission_denied`: the caller lacks the route''s permission, an API key on a route for dashboard users only, a session token on another route, or a refused `Unforged-Org`; `org_suspended`: the caller''s organisation (or one above it) is suspended.'
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://unforged.sh/problems/permission_denied
            title: Forbidden
            status: 403
            detail: requires nib:settings:manage
            code: permission_denied
            request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
    NotFound:
      description: '`not_found`: no such resource **for your tenant**.'
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://unforged.sh/problems/not_found
            title: Not found
            status: 404
            detail: subject not found
            code: not_found
            request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
    Conflict:
      description: |
        `conflict`, `idempotency_conflict` (the key was used with another body, or the job id
        with other inputs), `idempotency_in_progress` (the first request still runs) or
        `idempotency_not_replayable` (the key's response was a stream).
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://unforged.sh/problems/idempotency_conflict
            title: Idempotency key reused with a different request
            status: 409
            detail: this Idempotency-Key was used with a different request
            code: idempotency_conflict
            request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
    TooLarge:
      description: '`too_large`.'
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    RateLimited:
      description: '`rate_limited` (the per-tenant request rate) or `quota_exceeded` (the monthly quota).'
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://unforged.sh/problems/rate_limited
            title: Rate limit exceeded
            status: 429
            detail: too many requests; retry after 1 s
            code: rate_limited
            request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
    PaymentRequired:
      description: |
        `billing_required`: the tenant's billing does not allow checks (a Stripe-billed tenant
        without an active subscription). Add a payment method in the dashboard.
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://unforged.sh/problems/billing_required
            title: Billing required
            status: 402
            detail: 'add a payment method in the dashboard: /org/billing'
            code: billing_required
            request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
    NotImplemented:
      description: '`not_available_in_dev` (development deployments) or `not_implemented`.'
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Unavailable:
      description: |
        `busy` (no capacity; `Retry-After`; a check also carries `job_id` and `status_url` and
        continues asynchronously), `not_ready`, or `org_moving` (the organisation is being moved
        to another region; `Retry-After`).
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://unforged.sh/problems/busy
            title: Service busy
            status: 503
            detail: no capacity became free in time; the check was accepted as job inv-2291
            code: busy
            request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
            job_id: inv-2291
            status_url: /nib/checks/inv-2291
    WrongRegion:
      description: |
        `wrong_region` (421 Misdirected Request): the caller's organisation lives in another
        region. `region` names it and `api_host` is its regional API host; call that host (or
        `https://api.unforged.sh`, which routes by the key).
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://unforged.sh/problems/wrong_region
            title: Wrong region
            status: 421
            detail: this organisation's data lives in uk-london; call https://uk-london.api.unforged.sh
            code: wrong_region
            request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
            region: uk-london
            api_host: https://uk-london.api.unforged.sh
    Internal:
      description: '`internal` (the cause is logged, never returned).'
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }

  schemas:
    Me:
      type: object
      required: [tenant, caller, billing, sso_enabled, api_version]
      properties:
        billing: { $ref: '#/components/schemas/Billing' }
        sso_enabled: { type: boolean }
        sso_required:
          type: boolean
          description: The organisation has an active SSO connection (people who sign in with SSO stay inside it).
        api_version:
          type: object
          required: [pinned, latest, deprecated_at, sunset]
          properties:
            pinned: { type: string }
            latest: { type: string }
            deprecated_at: { type: [string, 'null'] }
            sunset: { type: [string, 'null'] }
        tenant:
          type: object
          required: [id, slug, name, region, home_region]
          properties:
            id: { type: string }
            slug: { type: string }
            name: { type: string }
            region:
              type: string
              description: The AWS region of the organisation's cell.
            home_region:
              type: string
              description: Where the organisation's data lives (`uk-london`, …), fixed at creation.
        caller:
          type: object
          required: [kind, id, role, permissions]
          properties:
            kind: { type: string, enum: [api_key, user] }
            id:
              type: string
              description: The API key id, or the WorkOS user id.
            role:
              type: [string, 'null']
              description: A dashboard user's role slug (a default role or a custom one); null for an API key.
            permissions:
              type: array
              description: The caller's effective permissions.
              items: { $ref: '#/components/schemas/Permission' }

    Billing:
      type: object
      required: [mode, status, plan_type, plan_state]
      properties:
        plan_type:
          type: string
          enum: [payg, payg_discounted, contract_unlimited, contract_commit, parent]
          description: The plan in effect today (see `GET /billing/plan`).
        plan_state:
          type: [string, 'null']
          enum: [in_date, in_grace, lapsed, null]
          description: A contract's state today (a sub-organisation's is its parent's); null otherwise.
        billed_through:
          type: [string, 'null']
          description: |
            For an organisation billed through its parent (`mode: parent`), the name of the
            organisation that pays; null otherwise. Only in `GET /me`.
        promo_credit:
          type: [object, 'null']
          description: |
            The sign-up credit (see `GET /billing/plan`; null without one), as of the
            organisation's cached row (a few seconds old at most; `GET /billing/plan` reads it
            afresh). Only in `GET /me`.
          required: [granted_usd, remaining_usd, converted, applies]
          properties:
            granted_usd: { type: string, description: The credit granted (US dollars). }
            remaining_usd: { type: string, description: What is left of it (US dollars; never below 0). }
            converted:
              type: boolean
              description: |
                A card was added; the remaining balance goes to the first invoices. Only on
                plain pay as you go: on any other plan then the credit is forfeited
                (`remaining_usd` is `0.00`).
            applies:
              type: boolean
              description: |
                Whether the credit applies to the organisation's plan: only on plain pay as you go
                (`payg`), billed through Stripe, while not `converted`. False on a discounted plan,
                a contract or for a sub-organisation: checks there never draw on it.
        mode:
          type: string
          enum: [stripe, invoiced, parent]
          description: |
            `stripe`: metered self-serve billing; `invoiced`: billed outside Stripe; `parent`
            (a child organisation): billed through its parent organisation.
        status:
          type: string
          enum: [pending, active, past_due, canceled]
          description: |
            `pending` until the first Checkout completes (checks run on the sign-up credit while
            any is left); `past_due` while Stripe retries a payment (checks still run); `canceled`
            after the subscription ends. Invoiced tenants are always `active`.

    BillingUrl:
      type: object
      required: [url]
      properties:
        url: { type: string, description: The Stripe page to send the user to. }

    TenantPlan:
      type: object
      additionalProperties: false
      required: [plan]
      properties:
        quote_id:
          type: string
          pattern: '^[A-Za-z0-9_-]{1,64}$'
          description: The accepted quote this plan comes from (history source `quote`).
        plan: { $ref: '#/components/schemas/BillingPlanVersion' }
        sso_enabled: { type: boolean }
        sso_required:
          type: boolean
          description: An override of the SSO ringfence flag (normally synced from WorkOS).

    BillingPlanVersion:
      type: object
      additionalProperties: false
      required: [type, starts_on]
      description: |
        A plan version. Contracts need `ends_on` (after `starts_on`) and `contract_value_usd`
        (staff reporting only); `payg_discounted` needs `unit_price_usd` and its
        customer-specific `stripe_price_id`; `contract_commit` needs `committed_amount_usd`,
        `overage_unit_price_usd` and exactly one of `included_checks` or `unit_price_usd`;
        `contract_unlimited` needs `meter_mode`. Fields that do not belong to the type are
        refused. Prices and amounts are positive decimal strings.
      properties:
        id: { type: integer, readOnly: true }
        type: { type: string, enum: [payg, payg_discounted, contract_unlimited, contract_commit, parent] }
        starts_on: { type: string, format: date }
        ends_on: { type: string, format: date }
        grace_days: { type: integer, minimum: 0, maximum: 365, default: 30 }
        contract_value_usd: { type: string, description: 'Reporting only (at most 2 decimal places).' }
        external_ref: { type: string, maxLength: 200, description: A quote or invoice id. }
        unit_price_usd: { type: string, description: 'USD per check (at most 4 decimal places).' }
        overage_unit_price_usd: { type: string, description: 'USD per check past the credit.' }
        committed_amount_usd: { type: string }
        included_checks: { type: integer, minimum: 1 }
        meter_mode:
          type: string
          enum: [included, none]
          description: '`included`: metered against the $0 included-checks price; `none`: recorded only.'
        fair_use_monthly_checks: { type: integer, minimum: 1, description: A notice past it; never blocks. }
        stripe_price_id: { type: string, pattern: '^price_[A-Za-z0-9]+$' }

    BillingPlanView:
      type: object
      required: [type, state, starts_on, ends_on, grace_ends_on, external_ref, unit_price_usd,
                 overage_unit_price_usd, included_checks, credit_remaining,
                 fair_use_monthly_checks, this_month, promo_credit]
      properties:
        type: { type: string, enum: [payg, payg_discounted, contract_unlimited, contract_commit, parent] }
        state: { type: [string, 'null'], enum: [in_date, in_grace, lapsed, null] }
        starts_on: { type: string, format: date }
        ends_on: { type: [string, 'null'], format: date }
        grace_ends_on: { type: [string, 'null'], format: date }
        external_ref: { type: [string, 'null'] }
        unit_price_usd: { type: [string, 'null'] }
        overage_unit_price_usd: { type: [string, 'null'] }
        included_checks: { type: [integer, 'null'] }
        credit_remaining:
          type: [object, 'null']
          description: '`{checks}` for an allowance, `{usd}` for a money credit.'
          properties:
            checks: { type: integer }
            usd: { type: string }
        fair_use_monthly_checks: { type: [integer, 'null'] }
        this_month:
          type: object
          required: [billable, overage]
          properties:
            billable: { type: integer }
            overage: { type: integer }
        promo_credit:
          type: [object, 'null']
          description: |
            The $10.00 sign-up credit every new organisation gets, with no card needed, read
            afresh (null without one: a sub-organisation, a contract). Each check with a verdict
            draws $0.50 from it (unreadable and unsigned documents are free). While it is not
            converted and anything is left, checks run without a card; a batch costing more than
            is left is refused (402 `billing_required`, naming the cost and the balance). Once a
            card is added (`converted`), nothing more is drawn: the balance left then is applied
            to the first invoices when the plan is plain pay as you go (`payg`); on any other plan
            it is forfeited (`remaining_usd` `0.00`).
          required: [granted_usd, remaining_usd, converted, applies]
          properties:
            granted_usd: { type: string, description: The credit granted (US dollars). }
            remaining_usd: { type: string, description: What is left of it (US dollars; never below 0). }
            converted:
              type: boolean
              description: |
                A card was added; the remaining balance goes to the first invoices. Only on
                plain pay as you go: on any other plan then the credit is forfeited
                (`remaining_usd` is `0.00`).
            applies:
              type: boolean
              description: |
                Whether the credit applies to the organisation's plan: only on plain pay as you go
                (`payg`), billed through Stripe, while not `converted`. False on a discounted plan,
                a contract or for a sub-organisation: checks there never draw on it.

    BillingCredits:
      type: object
      required: [items]
      properties:
        items:
          type: array
          description: One item per credit with anything left (money as US-dollar decimal strings).
          items:
            oneOf:
              - type: object
                description: The sign-up credit, while it applies.
                additionalProperties: false
                required: [kind, remaining_usd, granted_usd]
                properties:
                  kind: { type: string, const: signup }
                  remaining_usd: { type: string }
                  granted_usd: { type: string }
              - type: object
                description: A committed contract's remaining allowance of checks.
                additionalProperties: false
                required: [kind, remaining_checks]
                properties:
                  kind: { type: string, const: contract }
                  remaining_checks: { type: integer, minimum: 1 }
              - type: object
                description: A committed contract's remaining money.
                additionalProperties: false
                required: [kind, remaining_usd]
                properties:
                  kind: { type: string, const: contract }
                  remaining_usd: { type: string }
              - type: object
                description: The credit on the organisation's Stripe account, applied to the next invoices.
                additionalProperties: false
                required: [kind, remaining_usd]
                properties:
                  kind: { type: string, const: stripe }
                  remaining_usd: { type: string }

    MyOrgs:
      type: object
      required: [orgs]
      properties:
        orgs:
          type: array
          items:
            type: object
            required: [org_id, name, tenant_id, role, via, parent_org_id]
            properties:
              org_id: { type: string }
              name: { type: string }
              tenant_id: { type: string }
              role: { type: string, description: The role slug. }
              via:
                type: string
                enum: [membership, parent]
                description: '`membership`: switch by signing into it; `parent`: act in it with `Unforged-Org`.'
              parent_org_id: { type: [string, 'null'] }
      examples:
        - orgs:
            - { org_id: org_01j9x3m8v7k2q4r5s6t7u8v9w0, name: Acme Bank, tenant_id: ten_01j9x3m8v7k2q4r5s6t7u8v9w0, role: owner, via: membership, parent_org_id: null }
            - { org_id: org_01j9x3m8v7k2q4r5s6t7u8v9w1, name: Acme Leeds, tenant_id: ten_01j9x3m8v7k2q4r5s6t7u8v9w1, role: owner, via: parent, parent_org_id: org_01j9x3m8v7k2q4r5s6t7u8v9w0 }

    ChildOrg:
      type: object
      required: [org_id, tenant_id, name, status, billing_mode, sso_required, region, created_at]
      properties:
        org_id: { type: [string, 'null'] }
        tenant_id: { type: string }
        name: { type: string }
        status: { type: string, enum: [active, suspended, deregistered] }
        billing_mode: { type: string, enum: [stripe, invoiced, parent] }
        sso_required: { type: boolean }
        region:
          type: string
          description: The child's home region.
        created_at: { type: string }

    TeamMembers:
      type: object
      required: [members]
      properties:
        members:
          type: array
          items:
            type: object
            required: [user_id, email, name, role, status]
            properties:
              user_id: { type: string }
              email: { type: [string, 'null'] }
              name: { type: [string, 'null'] }
              role: { type: string, description: The role slug. }
              status: { type: string, description: 'WorkOS: active, pending or inactive.' }
      example:
        members:
          - { user_id: user_01J8, email: ada@example.com, name: Ada Lovelace, role: owner, status: active }

    TeamRoles:
      type: object
      required: [roles, custom_source]
      properties:
        roles:
          type: array
          items:
            type: object
            required: [slug, name, description, permissions, custom]
            properties:
              slug: { type: string }
              name: { type: string }
              description: { type: [string, 'null'] }
              permissions:
                type: array
                items: { $ref: '#/components/schemas/Permission' }
              custom: { type: boolean, description: Not one of the six default roles. }
        custom_source:
          type: string
          enum: [workos, members]
      example:
        roles:
          - slug: viewer
            name: Viewer
            description: Read checks and subjects
            permissions: ['org:read', 'nib:checks:read', 'nib:subjects:read']
            custom: false
        custom_source: workos

    AdminPortalRequest:
      type: object
      additionalProperties: false
      required: [intent]
      properties:
        intent: { type: string, enum: [sso, dsync] }
      example: { intent: sso }

    AdminPortalLink:
      type: object
      required: [url]
      properties:
        url: { type: string, description: The WorkOS Admin Portal page (short-lived). }

    ChildOrgList:
      type: object
      required: [children]
      properties:
        children:
          type: array
          items: { $ref: '#/components/schemas/ChildOrg' }

    ChildOrgCreate:
      type: object
      additionalProperties: false
      required: [name]
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        region:
          type: string
          description: The child's home region; default the parent's. A live region served here.
      examples:
        - { name: Acme Leeds }

    ChildOrgCreated:
      type: object
      required: [org_id, tenant_id, name, region]
      properties:
        org_id: { type: string }
        tenant_id: { type: string }
        name: { type: string }
        region: { type: string }

    ChildOrgStatus:
      type: object
      additionalProperties: false
      required: [status]
      properties:
        status: { type: string, enum: [active, suspended] }

    TenantPlanned:
      type: object
      required: [tenant_id, plan, effective, history_id, billing, sso_enabled, sso_required]
      properties:
        tenant_id: { type: string }
        plan: { $ref: '#/components/schemas/BillingPlanVersion' }
        effective: { $ref: '#/components/schemas/BillingPlanVersion' }
        history_id: { type: integer }
        billing:
          type: object
          required: [mode, status]
          properties:
            mode: { type: string, enum: [stripe, invoiced, parent] }
            status: { type: string, enum: [pending, active, past_due, canceled] }
        sso_enabled: { type: boolean }
        sso_required: { type: boolean }

    StripeEvent:
      type: object
      required: [id, type, created, data]
      description: A Stripe event object (only the members Unforged reads are listed).
      properties:
        id: { type: string }
        type: { type: string }
        created: { type: integer, description: Unix seconds. }
        data:
          type: object
          required: [object]
          properties:
            object: { type: object }

    TenantProvision:
      type: object
      additionalProperties: false
      required: [org_id, name, region]
      properties:
        org_id: { type: string, pattern: '^[A-Za-z0-9_]{1,64}$' }
        name: { type: string, minLength: 1, maxLength: 200 }
        region:
          type: string
          pattern: '^[a-z]{2}-[a-z]+$'
          description: The home region (a live region of `config/regions.json`, served by this host).
        email:
          type: string
          maxLength: 254
          description: The signing-up user's email, for the sign-up credit's Stripe customer.

    TenantProvisioned:
      type: object
      required: [tenant_id, slug, region, created]
      properties:
        tenant_id: { type: string }
        slug: { type: string }
        region:
          type: string
          description: The tenant's home region.
        created: { type: boolean }

    ContactRequest:
      type: object
      additionalProperties: false
      required: [name, email, topic, message]
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        email: { type: string, maxLength: 254, description: '`local@domain`' }
        company: { type: [string, 'null'], maxLength: 200 }
        topic: { type: string, enum: [volume, government, dedicated, sso, other] }
        message: { type: string, minLength: 1, maxLength: 2000 }
        website:
          type: [string, 'null']
          description: A honeypot; leave empty.

    Problem:
      type: object
      description: An RFC 9457 problem document (parent §7.5).
      required: [type, title, status, detail, code]
      properties:
        type:
          type: string
          description: '`https://unforged.sh/problems/<code>`'
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        code:
          type: string
          enum:
            - invalid_request
            - unsupported_media
            - too_large
            - fetch_failed
            - rate_limited
            - quota_exceeded
            - idempotency_conflict
            - internal
            - not_found
            - unauthorized
            - permission_denied
            - timeout
            - not_available_in_dev
            - method_not_allowed
            - conflict
            - busy
            - not_ready
            - idempotency_in_progress
            - idempotency_key_required
            - idempotency_not_replayable
            - not_implemented
            - not_allowlisted
            - tenant_not_provisioned
            - billing_required
            - invalid_signature
            - billing_already_active
            - invalid_version
            - org_suspended
            - wrong_region
            - org_moving
            - learning_terms_required
            - webhook_test_failed
          description: |
            `tenant_not_provisioned` (403): a dashboard token whose organisation has no tenant.
            `billing_required` (402): the tenant has no active billing. `invalid_signature`
            (400): a webhook whose signature does not verify. `billing_already_active` (409):
            the tenant already has a subscription; manage it in the billing portal.
            `invalid_version` (400): an `Unforged-Version` header that is malformed, unknown or
            past its sunset. `org_suspended` (403): the caller's organisation, or an
            organisation above it, is suspended (API keys and dashboard users alike).
            `wrong_region` (421): the caller's organisation lives in another region (see
            `region` and `api_host`).
            `org_moving` (503): Unforged is moving the organisation to another region;
            every request for it answers this, with `Retry-After`, until the move ends.
            `learning_terms_required` (409): `PUT /nib/settings/learning` refuses to turn
            learning on until the tenant accepts the current biometric-retention terms (see
            `terms_version`; `POST /nib/settings/learning/terms`).
            `webhook_test_failed` (400): `POST /nib/webhooks` sent the new endpoint a signed
            `webhook.test` event and got no 2xx within 10 s; nothing was created. The detail
            says why (the HTTP status, a timeout, or a failed connection).
        request_id: { type: string }
        region:
          type: string
          description: On `wrong_region`, the caller's home region.
        api_host:
          type: string
          description: On `wrong_region`, that region's API host.
        terms_version:
          type: string
          description: On `learning_terms_required`, the terms version the tenant must accept.
        job_id:
          type: string
          description: On `busy` / `timeout` of a check that continues asynchronously.
        status_url: { type: string }
        host:
          type: string
          description: On a failed URL fetch (`fetch_failed`, `not_allowlisted`, `too_large`, `timeout`).
        reason:
          type: string
          description: |
            On a failed URL fetch: a reason code such as `http_status`, `connect_failed`,
            `body_stalled` (the body arrived slower than 64 KiB per 10 s), `timeout` or
            `fetch_deadline` (a 504: all the URL fetches of one request share a 60 s deadline).
        checks:
          type: object
          description: On `/ready`'s `not_ready`.
        errors:
          type: array
          description: On a synchronous check whose job failed.
      examples:
        - type: https://unforged.sh/problems/not_found
          title: Not found
          status: 404
          detail: job not found
          code: not_found
          request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
        - type: https://unforged.sh/problems/timeout
          title: Request timed out
          status: 504
          detail: the item exceeded its 30 s budget; the check continues asynchronously
          code: timeout
          request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
          job_id: inv-2291
          status_url: /nib/checks/inv-2291

    # --- the check result -----------------------------------------------------
    Permission:
      type: string
      description: A permission of the catalogue (`docs/api/permissions.md`).
      enum: [org:read, org:members:manage, org:sso:manage, org:keys:manage, org:billing:manage, org:settings:manage, org:audit:read, org:children:access, org:children:manage, nib:checks:create, nib:checks:read, nib:subjects:read, nib:references:write, nib:references:delete, nib:settings:manage]

    ApiKeyCreate:
      type: object
      required: [name, permissions]
      properties:
        name: { type: string }
        permissions:
          type: array
          minItems: 1
          description: |
            The key's permissions: within the caller's own (else 403 `permission_denied`); an
            unknown name is 400 `invalid_request`. The presets are `checks` and `nib-admin`
            (`docs/api/permissions.md`).
          items: { $ref: '#/components/schemas/Permission' }
      additionalProperties: false
      examples:
        - { name: backend, permissions: ['nib:checks:create', 'nib:checks:read', 'nib:subjects:read'] }

    ApiKey:
      type: object
      required: [id, display_prefix, name, permissions, created_at, last_used_at, revoked_at]
      properties:
        id: { type: string }
        display_prefix:
          type: string
          description: |
            `unf_<env>_` and the key's first 8 characters, to recognise it (keys of one region
            share their first few characters); never used to look a key up.
        name: { type: string }
        permissions:
          type: array
          items: { $ref: '#/components/schemas/Permission' }
        created_at: { type: string }
        last_used_at: { type: [string, 'null'] }
        revoked_at: { type: [string, 'null'] }

    ApiKeyCreated:
      allOf:
        - $ref: '#/components/schemas/ApiKey'
        - type: object
          required: [key]
          properties:
            key:
              type: string
              description: The key, shown once.
      examples:
        - id: key_01j9x3m8v7k2q4r5s6t7u8v9w0
          display_prefix: unf_live_01AbCdEf
          name: backend
          permissions: ['nib:checks:create', 'nib:checks:read', 'nib:subjects:read']
          created_at: '2026-09-25T10:15:30.123Z'
          last_used_at: null
          revoked_at: null
          key: unf_live_01AbCdEf…EXAMPLE

    AuditPage:
      type: object
      required: [entries, next_cursor]
      properties:
        entries:
          type: array
          items:
            type: object
            required: [id, actor, action, target, detail, at]
            properties:
              id: { type: integer }
              actor: { type: string }
              action: { type: string }
              target: { type: [string, 'null'] }
              detail: {}
              actor_org_id:
                type: [string, 'null']
                description: The acting caller's organisation, on entries written by a request made with `Unforged-Org`; else null.
              at: { type: string }
        next_cursor: { type: [string, 'null'] }
      examples:
        - entries:
            - id: 42
              actor: api_key:key_01j9x3m8v7k2q4r5s6t7u8v9w0
              action: policy.create
              target: strict@2
              detail: {}
              at: '2026-09-25T10:15:30.123Z'
          next_cursor: '42'

    # --- webhooks ---------------------------------------------------------
    Ready:
      type: object
      description: >-
        `ready`, or `degraded` while the service runs with reduced capability
        (checks still complete). Which component is affected is never shown.
      required: [status]
      properties:
        status: { type: string, enum: [ready, degraded] }
      additionalProperties: false
      examples:
        - { status: ready }

    Version:
      type: object
      required: [model, api_version, region]
      properties:
        model:
          type: string
          description: The current model version (Unforged Nib v2.2 is `nib-2.2`).
        api_version:
          type: string
          description: The latest API version.
        region:
          type: string
          description: The region this host serves (`uk-london`, …).
      additionalProperties: false
      examples:
        - { model: nib-2.2, api_version: '2026-09-27', region: uk-london }

    ApiVersionState:
      type: object
      description: >-
        The organisation's API version pin. `previous` and `rollback_until`
        are set for 72 hours after a change, while a rollback is possible.
      required: [pinned, latest, previous, changed_at, rollback_until, versions]
      properties:
        pinned:
          type: string
          description: The pinned version (set when the organisation was created).
        latest: { type: string }
        previous: { type: [string, 'null'] }
        changed_at: { type: [string, 'null'] }
        rollback_until: { type: [string, 'null'] }
        versions:
          type: array
          items:
            type: object
            required: [date, summary, deprecated_at, sunset]
            properties:
              date: { type: string }
              summary: { type: string }
              deprecated_at: { type: [string, 'null'] }
              sunset: { type: [string, 'null'] }
      examples:
        - pinned: '2026-09-27'
          latest: '2026-09-27'
          previous: null
          changed_at: null
          rollback_until: null
          versions:
            - { date: '2026-09-27', summary: The first published version., deprecated_at: null, sunset: null }

    ApiVersionUpgrade:
      type: object
      required: [version]
      properties:
        version:
          type: string
          description: A published version newer than the pin and not past its sunset.
      additionalProperties: false
      examples:
        - { version: '2026-09-27' }
