openapi: 3.1.0
info:
  title: Qlaas DMI partner API
  version: 0.1.0
  summary: Provision and manage Qlaas DMI lines for your clients.
  description: |
    For agencies, hosts, contact-centre and booking platforms that run Qlaas DMI lines for their own clients.
    Source of truth: `app/src/app/api/partners/v1/*` and `app/supabase/migrations/0014_dmi_partners.sql` in the
    qlaas repository; the narrative is `docs/dmi/PARTNERS.md`. Where this file and the code disagree, the code is right.

    **Status.** Built and tested against an in-memory database only. Partner accounts start *pending*: a pending
    account can create up to five test lines, which are recorded but never put live and never email anyone. Test lines
    get a generated `t-<id>-<n>` handle; the handle you ask for is recorded, not reserved, until your account is active.
    Accounts are activated by Qlaas when DMI sales open. No charges are taken through this API.

    **Authentication** (private_key_jwt style; Qlaas never issues or stores a shared API secret):
    1. Generate an Ed25519 key pair on your own systems. Keep the private key there.
    2. In the console (`/partners`), register the public JWK `{"kty":"OKP","crv":"Ed25519","x":"…"}`.
       The console shows its `kid`: the first 16 characters of base64url(SHA-256(raw 32-byte public key)).
    3. For every request, sign a JWT with that key and send `Authorization: Bearer <JWT>`.

    | Part | Value |
    |---|---|
    | header | `{"alg":"EdDSA","kid":"<kid>","typ":"JWT"}` (`"Ed25519"` is also accepted as `alg`). No `jwk`, `jku`, `x5u`, `x5c` or `crit`. |
    | `iss` | your partner id (UUID, shown in the console) |
    | `aud` | `https://console.qlaas.co.uk/api/partners` (a string, or an array containing it) |
    | `iat`, `exp` | integer seconds; `exp - iat` ≤ 300; 30 s clock skew allowed |
    | `jti` | 16–128 characters `[A-Za-z0-9_-]`, single use: mint a fresh token per request |

    Anything else (`alg: none`, HS256, RS256, an expired or reused token, a revoked key) is refused with 401.
    A suspended partner's tokens are refused with 403.

    **Limits.** 240 requests a minute per IP; 120 a minute per partner; 30 writes a minute per partner. A 429 carries
    `Retry-After`.

    **Provisioning.** A created line joins the same queue as a bought line. The console's reconcile run (every five
    minutes) starts it on the host; when it is live the line emails `owner_email` a one-time sign-in code. Suspend
    and resume take effect on the host at the next run. Lines are served at `https://<handle>.dmi.qlaas.co.uk`.
servers:
  - url: https://console.qlaas.co.uk/api/partners/v1
security:
  - partnerJwt: []
paths:
  /lines:
    get:
      operationId: listLines
      summary: List your client lines, newest first
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 200 }
        - name: before
          in: query
          description: Return lines created before this time (use `next_before` from the previous page).
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Lines
          content:
            application/json:
              schema:
                type: object
                required: [lines, next_before]
                properties:
                  lines: { type: array, items: { $ref: "#/components/schemas/Line" } }
                  next_before: { type: [string, "null"], format: date-time }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "403": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: createLine
      summary: Create a client line (idempotent on external_ref)
      description: |
        A repeat with the same `external_ref`, `handle` and `owner_email` returns the existing line with
        `created: false` (200). A repeat that disagrees on `handle` or `owner_email` is a 409 `idempotency_conflict`.
        Pending partners get test-mode lines (`livemode: false`) whose `handle` is generated in the test namespace
        (`t-<12 hex>-<n>`); the requested handle is returned as `requested_handle` and is not reserved.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NewLine" }
            example: { client_label: "Smith Dental", handle: "smith-dental", owner_email: "frontdesk@smithdental.example", external_ref: "crm-1042" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LineResult" }
        "200":
          description: Already existed (idempotent repeat)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LineResult" }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "413": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /lines/{id}/suspend:
    post:
      operationId: suspendLine
      summary: Suspend a client line
      description: The line's door closes at the next reconcile run; its data is kept. Idempotent.
      parameters: [{ $ref: "#/components/parameters/LineId" }]
      responses:
        "200":
          description: Done
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StateResult" }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /lines/{id}/resume:
    post:
      operationId: resumeLine
      summary: Resume a suspended client line
      description: Not allowed while the partner account is suspended. Idempotent.
      parameters: [{ $ref: "#/components/parameters/LineId" }]
      responses:
        "200":
          description: Done
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StateResult" }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /usage:
    get:
      operationId: getUsage
      summary: Wholesale usage summary
      description: |
        `billable_lines` counts live-mode lines that are not suspended (setting up or live). Test lines and suspended
        lines are not billable. Prices are in your partner agreement, not in this API.
      responses:
        "200":
          description: Usage
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Usage" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "403": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
components:
  securitySchemes:
    partnerJwt:
      type: http
      scheme: bearer
      bearerFormat: JWT (EdDSA, signed with your registered Ed25519 key)
  parameters:
    LineId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
  responses:
    Error:
      description: Error
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorised:
      description: Missing, invalid, expired, replayed or unknown-key token
      headers:
        WWW-Authenticate: { schema: { type: string }, description: 'Bearer error="invalid_token", error_description="<code>"' }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Too many requests
      headers:
        Retry-After: { schema: { type: integer } }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: |
                Authentication: missing_token, invalid_token, token_expired, unknown_key, bad_signature, replayed,
                partner_suspended. Requests: invalid_body, body_too_large, invalid_id, invalid_before, rate_limited.
                Lines: handle_invalid, handle_reserved, handle_taken, invalid_ref, invalid_label, invalid_email,
                idempotency_conflict, line_limit, line_not_found, suspended.
            message: { type: string }
    NewLine:
      type: object
      required: [client_label, handle, owner_email, external_ref]
      properties:
        client_label: { type: string, minLength: 1, maxLength: 120, description: Your client's name, shown to you only. }
        handle:
          type: string
          description: |
            The line's address: `https://<handle>.dmi.qlaas.co.uk`. 2–32 characters, lower-case letters, digits and
            single hyphens, not starting or ending with a hyphen. A leading `@` is dropped; `_` and `.` become `-`.
            Reserved names (admin, api, support, …), the test namespace `t-<12 hex>-<n>` and taken handles are refused
            (taken is checked for active partners only: a test line never holds the handle it asked for).
        owner_email: { type: string, format: email, maxLength: 320, description: The line owner; the line emails this address a one-time sign-in code when it is live. }
        external_ref: { type: string, pattern: "^[A-Za-z0-9._:-]{1,100}$", description: Your id for this client; the idempotency key. }
    Line:
      type: object
      required: [id, client_label, external_ref, handle, state, status, livemode, owner_email, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        client_label: { type: string }
        external_ref: { type: string }
        handle: { type: string, description: "The line's address. Test lines: a generated `t-<12 hex>-<n>` handle." }
        requested_handle: { type: [string, "null"], description: The handle you asked for (equal to handle on live lines). }
        state: { type: string, enum: [active, suspended], description: Your switch. }
        status:
          type: string
          enum: [test, setting_up, live, suspended, attention]
          description: |
            test: a test-mode line (never put live). setting_up: queued or being started on the host. live: answering.
            suspended: you suspended it. attention: the host refused it; see last_error.
        livemode: { type: boolean }
        owner_email: { type: string }
        door_url: { type: [string, "null"], description: "Public door, e.g. https://smith-dental.dmi.qlaas.co.uk/@smith-dental; null until live." }
        app_url: { type: [string, "null"], description: Owner app; null until live. }
        provisioned_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        last_error: { type: [string, "null"] }
    LineResult:
      type: object
      required: [created, line]
      properties:
        created: { type: boolean }
        line: { $ref: "#/components/schemas/Line" }
    StateResult:
      type: object
      required: [changed, line]
      properties:
        changed: { type: boolean }
        line: { $ref: "#/components/schemas/Line" }
    Usage:
      type: object
      properties:
        partner_id: { type: string, format: uuid }
        partner_status: { type: string, enum: [pending, active, suspended] }
        line_limit: { type: integer }
        as_of: { type: string, format: date-time }
        month_start: { type: string, format: date-time }
        lines_total: { type: integer }
        billable_lines: { type: integer }
        live_lines: { type: integer }
        provisioning_lines: { type: integer }
        suspended_lines: { type: integer }
        test_lines: { type: integer }
        created_this_month: { type: integer }
        created_this_month_live: { type: integer }
