openapi: 3.1.0
info:
  title: MessagePipe Management API
  version: 0.1.0
  summary: Customer-facing control-plane for the MessagePipe platform.
  description: |
    The Management API serves the Customer App: authentication and
    sessions, workspaces and projects, API keys, provider configurations,
    templates, webhook subscriptions and billing. Every authenticated
    endpoint requires the `mp_session` cookie issued by `/auth/sign-in`.

    Errors follow the envelope: `{ "error": { "code", "message",
    "details?", "request_id", "documentation_url" } }`. The Send API
    (`api.dev.messagepipe.dev/v1/*`) and Admin API are documented separately.
  contact:
    name: MessagePipe Engineering
    url: https://docs.dev.messagepipe.dev
servers:
  - url: https://mgmt.dev.messagepipe.dev/api
    description: Development
  - url: http://localhost:8090/api
    description: Local
tags:
  - { name: Auth, description: Sign-up, sign-in and the session lifecycle }
  - { name: Managed email, description: MessagePipe-run sending on Amazon SES - sender domains (tenants) and their DNS records }
  - {
      name: Passkeys,
      description: WebAuthn passkey registration and authentication,
    }
  - {
      name: Email Verification,
      description: Six-digit code flow for the sign-up email,
    }
  - { name: Password Reset, description: Public token-link flow }
  - { name: MFA, description: TOTP enrollment and the MFA sign-in completion }
  - { name: Workspaces, description: Workspaces, members and invitations }
  - { name: Projects, description: Projects within a workspace }
  - { name: API Keys, description: Provisioning and rotating API keys }
  - { name: Providers, description: Provider configurations and health }
  - { name: Templates, description: Templates and their versions }
  - { name: Webhooks, description: Outbound webhook endpoints and deliveries }
  - { name: Billing, description: Subscriptions, credits, top-ups, invoices }
  - {
      name: Notifications,
      description: Per-user email notification preferences,
    }
  - { name: Brands, description: "Brands (sender identities) and their sender domains" }
  - { name: Topics, description: "Subscription topics on a brand" }
  - { name: Consent statements, description: "Versioned consent wording; evidence once used" }
  - { name: Contacts, description: "Audience contacts, consent evidence, export and erasure" }
  - { name: Suppressions, description: "Addresses that must not be mailed" }
  - { name: Segments, description: "Dynamic (rule-based) and static (hand-picked) groups of contacts" }
  - { name: Imports, description: "CSV contact import with a consent attestation and a dry run" }
  - { name: Workspace settings, description: "Suppression mode and unsubscribe scope" }
  - { name: Content, description: "Campaign templates, brand layouts, preview, pre-flight checks and test sends" }
  - { name: Campaigns, description: "Campaigns and newsletters: drafts, pre-flight, send, schedule, pause, cancel and results" }
  - { name: Signup forms, description: "Hosted and embeddable signup forms that record express consent against a registered statement" }
  - { name: Account transfer, description: "Export a whole workspace as a passphrase-sealed bundle and import it into another instance" }

# ─── Reusable components ─────────────────────────────────────────────────────
components:
  securitySchemes:
    sessionCookie:
      type: apiKey
      in: cookie
      name: mp_session
      description: HMAC-signed opaque session id; minted by `/auth/sign-in`.
  schemas:
    CampaignContent:
      type: object
      required: [mode, source]
      properties:
        mode: { type: string, enum: [markdown, html, mjml] }
        source: { type: string, description: "Markdown, HTML or MJML as written; at most 1 MB (MJML 256 KB)" }
        subject: { type: string, maxLength: 998 }
        preheader: { type: string, maxLength: 300 }
        text: { type: string, description: "Optional hand-written plain-text part; generated from the HTML when empty" }
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, request_id, documentation_url]
          properties:
            code:
              {
                type: string,
                examples:
                  [
                    validation_failed,
                    unauthorized,
                    not_found,
                    credits_exhausted,
                    rate_limit_exceeded,
                  ],
              }
            message: { type: string }
            details: { type: object, additionalProperties: true }
            request_id: { type: string, examples: [req_06F4Y9CP1GQB39AA] }
            documentation_url: { type: string, format: uri }
    Id:
      {
        type: string,
        pattern: "^[a-z]+_[A-Z0-9]+$",
        examples: [wks_8C4E2A1F, usr_06F4Y9CP1GQB39AA],
      }
    Email: { type: string, format: email }
    Timestamp: { type: string, format: date-time }
    User:
      type: object
      properties:
        id: { $ref: "#/components/schemas/Id" }
        email: { $ref: "#/components/schemas/Email" }
        full_name: { type: string }
        email_verified: { type: boolean }
        mfa_enabled:
          {
            type: boolean,
            description: Whether the user has an active TOTP factor.,
          }
        created_at: { $ref: "#/components/schemas/Timestamp" }
    NotificationPreferences:
      type: object
      properties:
        product_updates: { type: boolean }
        delivery_alerts: { type: boolean }
        weekly_digest: { type: boolean }
        billing_alerts: { type: boolean }
    WorkspaceSummary:
      type: object
      properties:
        id: { $ref: "#/components/schemas/Id" }
        name: { type: string }
        slug: { type: string }
        plan: { type: string, enum: [free, special, pro, scale, enterprise] }
        status: { type: string, enum: [active, suspended, deleted] }
        region: { type: string }
        role:
          {
            type: string,
            enum:
              [workspace_owner, workspace_admin, developer, marketer, viewer],
          }
        created_at: { $ref: "#/components/schemas/Timestamp" }
    SessionResponse:
      oneOf:
        - title: Direct sign-in (no MFA)
          type: object
          required: [user]
          properties:
            user: { $ref: "#/components/schemas/User" }
            current_workspace: { type: [string, "null"] }
            available_workspaces:
              {
                type: array,
                items: { $ref: "#/components/schemas/WorkspaceSummary" },
              }
        - title: MFA required
          type: object
          required: [mfa_required, mfa_ticket]
          properties:
            mfa_required: { type: boolean, const: true }
            factors:
              { type: array, items: { type: string, enum: [totp, recovery] } }
            mfa_ticket: { type: string }
            expires_at: { $ref: "#/components/schemas/Timestamp" }
    Channel: { type: string, enum: [email, sms, chat, push] }
    Plan: { type: string, enum: [free, special, pro, scale, enterprise] }
    Subscription:
      type: object
      properties:
        workspace_id: { $ref: "#/components/schemas/Id" }
        plan: { $ref: "#/components/schemas/Plan" }
        plan_name: { type: string }
        status:
          { type: string, enum: [active, past_due, canceled, paused, trialing] }
        price_cents: { type: integer }
        quotas:
          type: object
          additionalProperties: { type: integer }
        current_period_start: { $ref: "#/components/schemas/Timestamp" }
        current_period_end: { $ref: "#/components/schemas/Timestamp" }
        cancel_at_period_end: { type: boolean }
        next_plan: { type: [string, "null"] }
        trial_end_at: { type: [string, "null"], format: date-time }
        auto_recharge: { type: object, additionalProperties: true }
        stripe_connected: { type: boolean }
        plan_changes_used:
          { type: integer, description: In-period plan changes made in this billing period }
        plan_changes_max:
          { type: integer, description: In-period plan changes allowed before the rest wait for the renewal }
    CreditBalance:
      type: object
      properties:
        channel: { $ref: "#/components/schemas/Channel" }
        monthly_quota: { type: integer }
        monthly_remaining: { type: integer }
        topup_balance: { type: integer }
        total_available: { type: integer }
        source: { type: string, enum: [live, postgres] }
    ApiKeyCreated:
      type: object
      properties:
        id: { $ref: "#/components/schemas/Id" }
        prefix: { type: string }
        key: { type: string, description: "Shown exactly once — store it now." }
        scopes: { type: array, items: { type: string } }
        created_at: { $ref: "#/components/schemas/Timestamp" }
    Member:
      type: object
      properties:
        id: { $ref: "#/components/schemas/Id" }
        user_id: { $ref: "#/components/schemas/Id" }
        role:
          {
            type: string,
            enum:
              [workspace_owner, workspace_admin, developer, marketer, viewer],
          }
        status: { type: string, enum: [active, invited, suspended] }
        joined_at: { $ref: "#/components/schemas/Timestamp" }
        email: { $ref: "#/components/schemas/Email" }
        full_name: { type: string }
    Provider:
      type: object
      properties:
        id: { $ref: "#/components/schemas/Id" }
        workspace_id: { $ref: "#/components/schemas/Id" }
        project_id: { type: [string, "null"] }
        provider_type: { type: string, examples: [sendgrid, twilio, mock] }
        channel: { $ref: "#/components/schemas/Channel" }
        display_name: { type: string }
        config: { type: object, additionalProperties: true }
        overrides: { $ref: "#/components/schemas/ProviderOverrides" }
        priority: { type: integer }
        weight: { type: integer }
        status: { type: string, enum: [active, disabled] }
        health_status:
          { type: string, enum: [unknown, healthy, degraded, down] }
        last_test_at: { type: [string, "null"], format: date-time }
        last_test_result: { type: [object, "null"], additionalProperties: true }
        created_at: { $ref: "#/components/schemas/Timestamp" }
    ProviderOverrides:
      type: object
      additionalProperties: false
      description: >
        Settings one email provider applies to the sends it carries, in place of the project's. Every key is
        optional and an empty object inherits everything. For the sender the order is: a `from` on the request
        or in the template, then this provider's override, then the project's default sender. The provider
        that takes a send is only known at dispatch, so on a failover the next provider's overrides apply.
        A provider's From address is not checked against the project's `sender_domains` or a brand's verified
        domains, so the domain must be set up for sending with the provider.
      properties:
        from_email:
          type: string
          maxLength: 254
          description: Bare address, used when the send names no `from.email`.
        from_name: { type: string, maxLength: 200 }
        reply_to:
          type: string
          maxLength: 254
          description: Bare address.
        disable_tracking:
          type: boolean
          description: Switches off the provider's own open and click tracking. It cannot switch tracking on.
        headers:
          type: object
          maxProperties: 20
          additionalProperties: { type: string }
          description: >
            Default custom headers. A header of the same name on the request wins. Platform-owned names
            (`From`, `Reply-To`, `X-SES-*`, ...) are refused, as they are on a send.
    ProviderTestResult:
      type: object
      properties:
        ok: { type: boolean }
        detail: { type: string }
    SenderIdResult:
      type: object
      properties:
        ok: { type: boolean }
        supported:
          type: boolean
          description: False when the provider has no sender ID registration; nothing was attempted.
        sender_ids:
          type: array
          items:
            type: object
            properties:
              sender_id: { type: string }
              status: { type: string, description: "As the provider reports it: active, pending, blocked." }
              company: { type: string }
              use_case: { type: string }
              country: { type: string }
              created_at: { type: string }
        detail: { type: string, description: The provider's confirmation, or why the call failed. }
        code: { type: string }
    VariableSchema:
      type: object
      description: A variable extracted from template content.
      properties:
        name: { type: string }
        required: { type: boolean }
        sample_value: { type: string }
        description: { type: string }
    WebhookStatus:
      {
        type: string,
        enum: [pending_verification, active, failed_verification, disabled],
      }
    WebhookEventType:
      type: string
      enum:
        [
          queued,
          dispatching,
          sent,
          delivered,
          opened,
          clicked,
          bounced,
          failed,
          complaint,
          unsubscribed,
          deferred,
          read,
          replied,
          suppressed,
          contact.created,
          contact.updated,
          contact.erased,
          consent.updated,
          consent.expired,
          suppression.created,
          suppression.removed,
        ]
  responses:
    Unauthorized:
      {
        description: Missing or invalid session cookie,
        content:
          {
            application/json:
              { schema: { $ref: "#/components/schemas/Error" } },
          },
      }
    Forbidden:
      {
        description: Authenticated but not allowed,
        content:
          {
            application/json:
              { schema: { $ref: "#/components/schemas/Error" } },
          },
      }
    NotFound:
      {
        description: Resource does not exist or is not visible,
        content:
          {
            application/json:
              { schema: { $ref: "#/components/schemas/Error" } },
          },
      }
    Conflict:
      {
        description: Resource state prevents the operation,
        content:
          {
            application/json:
              { schema: { $ref: "#/components/schemas/Error" } },
          },
      }
    ValidationFailed:
      {
        description: Request body or query failed validation,
        content:
          {
            application/json:
              { schema: { $ref: "#/components/schemas/Error" } },
          },
      }
  parameters:
    ProjectIdPath:
      name: projectId
      in: path
      required: true
      schema: { $ref: "#/components/schemas/Id" }
    WorkspaceIdQuery:
      name: workspace_id
      in: query
      required: true
      schema: { $ref: "#/components/schemas/Id" }

security: []

# ─── Paths ───────────────────────────────────────────────────────────────────
paths:
  /auth/sign-up:
    post:
      tags: [Auth]
      summary: Create a new account
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password, full_name]
              properties:
                email: { $ref: "#/components/schemas/Email" }
                password: { type: string, minLength: 8 }
                full_name: { type: string, minLength: 1 }
                country_code: { type: string, minLength: 2, maxLength: 2 }
      responses:
        "201":
          description: User created; verify the email next.
          content:
            {
              application/json:
                {
                  schema:
                    {
                      type: object,
                      properties:
                        {
                          user_id: { $ref: "#/components/schemas/Id" },
                          email: { $ref: "#/components/schemas/Email" },
                          redirect: { type: string },
                        },
                    },
                },
            }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "409": { $ref: "#/components/responses/Conflict" }

  /auth/sign-in:
    post:
      tags: [Auth]
      summary: Exchange credentials for a session cookie
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { $ref: "#/components/schemas/Email" }
                password: { type: string }
      responses:
        "200":
          description: Either a fresh session, or `mfa_required` when 2FA is enrolled.
          content:
            {
              application/json:
                { schema: { $ref: "#/components/schemas/SessionResponse" } },
            }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/sign-out:
    post:
      tags: [Auth]
      summary: Revoke the current session
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: Signed out.
          content:
            {
              application/json:
                {
                  schema:
                    {
                      type: object,
                      properties:
                        { status: { type: string, const: signed_out } },
                    },
                },
            }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/me:
    get:
      tags: [Auth]
      summary: The authenticated user + their workspaces
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: Current user.
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { $ref: "#/components/schemas/User" }
                  current_workspace: { type: [string, "null"] }
                  available_workspaces:
                    {
                      type: array,
                      items: { $ref: "#/components/schemas/WorkspaceSummary" },
                    }
        "401": { $ref: "#/components/responses/Unauthorized" }
    patch:
      tags: [Auth]
      summary: Update the signed-in user's profile
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          {
            application/json:
              {
                schema:
                  {
                    type: object,
                    required: [full_name],
                    properties:
                      {
                        full_name:
                          { type: string, minLength: 1, maxLength: 200 },
                      },
                  },
              },
          }
      responses:
        "200":
          {
            description: Updated,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            status: { type: string, const: updated },
                            full_name: { type: string },
                          },
                      },
                  },
              },
          }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    delete:
      tags: [Auth]
      summary: Delete the signed-in user's account
      description: Requires typing the account email to confirm. Soft-deletes solely-owned workspaces and revokes every session; fails if the user still owns a shared workspace.
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          {
            application/json:
              {
                schema:
                  {
                    type: object,
                    required: [confirm_email],
                    properties:
                      {
                        confirm_email:
                          { type: string, minLength: 1, maxLength: 320 },
                      },
                  },
              },
          }
      responses:
        "200":
          {
            description: "Account deleted; the session cookie is cleared.",
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          { status: { type: string, const: deleted } },
                      },
                  },
              },
          }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }

  /auth/sessions:
    get:
      tags: [Auth]
      summary: List the user's active sessions
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: Active sessions.
          content:
            {
              application/json:
                {
                  schema:
                    {
                      type: object,
                      properties:
                        { data: { type: array, items: { type: object } } },
                    },
                },
            }

  /auth/sessions/{id}:
    delete:
      tags: [Auth]
      summary: Revoke one session (the current or another device)
      security: [{ sessionCookie: [] }]
      parameters:
        [
          {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          },
        ]
      responses:
        "200":
          {
            description: Revoked,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties: { status: { type: string } },
                      },
                  },
              },
          }
        "404": { $ref: "#/components/responses/NotFound" }

  /auth/email/verify:
    post:
      tags: [Email Verification]
      summary: Consume the six-digit verification code
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          {
            application/json:
              {
                schema:
                  {
                    type: object,
                    required: [code],
                    properties:
                      { code: { type: string, pattern: "^[0-9]{6}$" } },
                  },
              },
          }
      responses:
        "200":
          description: Verified.
          content:
            {
              application/json:
                {
                  schema:
                    {
                      type: object,
                      properties:
                        { email_verified: { type: boolean, const: true } },
                    },
                },
            }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "404": { $ref: "#/components/responses/NotFound" }

  /auth/email/resend:
    post:
      tags: [Email Verification]
      summary: Mint a fresh verification code
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          {
            description: Sent,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            status:
                              { type: string, enum: [sent, already_verified] },
                          },
                      },
                  },
              },
          }

  /auth/password-reset/request:
    post:
      tags: [Password Reset]
      summary: Request a password reset link (public)
      description: Always returns 200 so the response cannot confirm registration.
      requestBody:
        required: true
        content:
          {
            application/json:
              {
                schema:
                  {
                    type: object,
                    required: [email],
                    properties:
                      { email: { $ref: "#/components/schemas/Email" } },
                  },
              },
          }
      responses:
        "200":
          {
            description: Always sent,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties: { status: { type: string, const: sent } },
                      },
                  },
              },
          }

  /auth/password-reset/confirm:
    post:
      tags: [Password Reset]
      summary: Confirm a password reset and rotate the password (public)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, new_password]
              properties:
                token: { type: string }
                new_password: { type: string, minLength: 8 }
      responses:
        "200":
          description: Password rotated; every live session for the user has been revoked.
          content:
            {
              application/json:
                {
                  schema:
                    {
                      type: object,
                      properties:
                        {
                          status: { type: string, const: password_reset },
                          sessions_revoked: { type: integer },
                        },
                    },
                },
            }
        "404": { $ref: "#/components/responses/NotFound" }

  /auth/2fa/totp/enroll:
    post:
      tags: [MFA]
      summary: Generate a candidate TOTP secret + provisioning URI
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: Candidate secret; not yet persisted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  secret: { type: string, description: RFC 4648 base32 }
                  otpauth_url: { type: string }
                  issuer: { type: string }
                  algorithm: { type: string, const: SHA1 }
                  digits: { type: integer, const: 6 }
                  period: { type: integer, const: 30 }

  /auth/2fa/totp/activate:
    post:
      tags: [MFA]
      summary: Prove possession of the enrolled secret and persist it
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [secret, code]
              properties:
                secret: { type: string }
                code: { type: string, pattern: "^[0-9]{6}$" }
      responses:
        "201":
          description: Factor enrolled; ten one-time recovery codes are returned exactly here.
          content:
            application/json:
              schema:
                type: object
                properties:
                  enrolled: { type: boolean, const: true }
                  recovery_codes: { type: array, items: { type: string } }

  /auth/2fa/totp:
    delete:
      tags: [MFA]
      summary: Disable TOTP (requires a current code)
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          {
            application/json:
              {
                schema:
                  {
                    type: object,
                    required: [code],
                    properties:
                      { code: { type: string, pattern: "^[0-9]{6}$" } },
                  },
              },
          }
      responses:
        "200":
          {
            description: Disabled,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          { enrolled: { type: boolean, const: false } },
                      },
                  },
              },
          }

  /auth/2fa/recovery/regenerate:
    post:
      tags: [MFA]
      summary: Regenerate the ten one-time recovery codes (requires a current code)
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          {
            application/json:
              {
                schema:
                  {
                    type: object,
                    required: [code],
                    properties:
                      { code: { type: string, pattern: "^[0-9]{6}$" } },
                  },
              },
          }
      responses:
        "200":
          description: A fresh set of recovery codes; the previous set is invalidated.
          content:
            {
              application/json:
                {
                  schema:
                    {
                      type: object,
                      properties:
                        {
                          recovery_codes:
                            { type: array, items: { type: string } },
                        },
                    },
                },
            }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/2fa/sign-in:
    post:
      tags: [MFA]
      summary: Complete an MFA-required sign-in (public)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mfa_ticket, factor, code]
              properties:
                mfa_ticket: { type: string }
                factor: { type: string, enum: [totp, recovery] }
                code: { type: string }
      responses:
        "200":
          description: Session issued; a recovery code is consumed on use.
          content:
            {
              application/json:
                { schema: { $ref: "#/components/schemas/SessionResponse" } },
            }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/refresh:
    post:
      tags: [Auth]
      summary: Rotate the current session
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          {
            description: "Session rotated; a fresh cookie is issued.",
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          { status: { type: string, const: refreshed } },
                      },
                  },
              },
          }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/change-password:
    post:
      tags: [Auth]
      summary: Change the password for the signed-in user
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current_password, new_password]
              properties:
                current_password: { type: string }
                new_password: { type: string, minLength: 8 }
      responses:
        "200":
          description: "Password rotated; other live sessions are revoked."
          content:
            {
              application/json:
                {
                  schema:
                    {
                      type: object,
                      properties:
                        {
                          status: { type: string, const: password_changed },
                          sessions_revoked: { type: integer },
                        },
                    },
                },
            }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/oauth/{provider}/start:
    get:
      tags: [Auth]
      summary: Begin an OAuth sign-in by redirecting to the identity provider
      parameters:
        [
          {
            name: provider,
            in: path,
            required: true,
            schema: { type: string, enum: [google, github] },
          },
        ]
      responses:
        "302": { description: "Redirect to the IdP authorization endpoint." }

  /auth/oauth/{provider}/callback:
    get:
      tags: [Auth]
      summary: Complete an OAuth sign-in from the provider callback
      parameters:
        - {
            name: provider,
            in: path,
            required: true,
            schema: { type: string, enum: [google, github] },
          }
        - { name: code, in: query, required: true, schema: { type: string } }
        - { name: state, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: "Session issued."
          content:
            {
              application/json:
                {
                  schema:
                    {
                      type: object,
                      properties:
                        {
                          status: { type: string },
                          user_id: { $ref: "#/components/schemas/Id" },
                          current_workspace: { type: [string, "null"] },
                          available_workspaces:
                            { type: array, items: { type: object } },
                        },
                    },
                },
            }
        "302":
          {
            description: "Redirect back into the app with a session cookie set.",
          }

  /auth/webauthn/register/start:
    post:
      tags: [Passkeys]
      summary: Begin passkey registration
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: "PublicKeyCredentialCreationOptions to pass to navigator.credentials.create()."
          content:
            {
              application/json:
                { schema: { type: object, additionalProperties: true } },
            }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/webauthn/register/finish:
    post:
      tags: [Passkeys]
      summary: Complete passkey registration
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [response]
              properties:
                response: { type: object, additionalProperties: true }
                display_name: { type: string }
      responses:
        "200":
          description: Registered.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { $ref: "#/components/schemas/Id" }
                  display_name: { type: string }
                  status: { type: string, const: registered }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409":
          {
            description: This authenticator already holds a passkey for this platform.,
          }

  /auth/webauthn/authenticate/start:
    post:
      tags: [Passkeys]
      summary: Begin passkey authentication (public)
      requestBody:
        content:
          {
            application/json:
              {
                schema:
                  {
                    type: object,
                    properties:
                      { email: { $ref: "#/components/schemas/Email" } },
                  },
              },
          }
      responses:
        "200":
          description: "An opaque handle plus PublicKeyCredentialRequestOptions."
          content:
            {
              application/json:
                {
                  schema:
                    {
                      type: object,
                      properties:
                        {
                          handle: { type: string },
                          options: { type: object, additionalProperties: true },
                        },
                    },
                },
            }
        "429": { description: Too many passkey attempts from this IP. }

  /auth/webauthn/authenticate/finish:
    post:
      tags: [Passkeys]
      summary: Complete passkey authentication (public)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [handle, response]
              properties:
                handle: { type: string }
                response: { type: object, additionalProperties: true }
      responses:
        "200":
          description: "Session issued."
          content:
            {
              application/json:
                {
                  schema:
                    {
                      type: object,
                      properties:
                        {
                          status: { type: string },
                          user_id: { $ref: "#/components/schemas/Id" },
                          current_workspace: { type: [string, "null"] },
                        },
                    },
                },
            }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { description: The account is suspended or otherwise not active. }
        "429": { description: Too many passkey attempts from this IP. }

  /auth/passkeys:
    get:
      tags: [Passkeys]
      summary: List the user's registered passkeys
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: Passkeys.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { $ref: "#/components/schemas/Id" }
                        display_name: { type: string }
                        created_at: { $ref: "#/components/schemas/Timestamp" }
                        last_used_at:
                          { type: [string, "null"], format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /auth/passkeys/{id}:
    parameters:
      [
        {
          name: id,
          in: path,
          required: true,
          schema: { $ref: "#/components/schemas/Id" },
        },
      ]
    patch:
      tags: [Passkeys]
      summary: Rename a passkey
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          {
            application/json:
              {
                schema:
                  {
                    type: object,
                    required: [display_name],
                    properties: { display_name: { type: string } },
                  },
              },
          }
      responses:
        "200":
          {
            description: Renamed,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            id: { $ref: "#/components/schemas/Id" },
                            display_name: { type: string },
                          },
                      },
                  },
              },
          }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Passkeys]
      summary: Remove a passkey
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          {
            description: Deleted,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          { status: { type: string, const: deleted } },
                      },
                  },
              },
          }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces:
    get:
      tags: [Workspaces]
      summary: List workspaces the caller belongs to
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: Active memberships.
          content:
            {
              application/json:
                {
                  schema:
                    {
                      type: object,
                      properties:
                        {
                          data:
                            {
                              type: array,
                              items:
                                {
                                  $ref: "#/components/schemas/WorkspaceSummary",
                                },
                            },
                        },
                    },
                },
            }
    post:
      tags: [Workspaces]
      summary: Create a workspace
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          {
            application/json:
              {
                schema:
                  {
                    type: object,
                    required: [name],
                    properties:
                      { name: { type: string, minLength: 2, maxLength: 100 } },
                  },
              },
          }
      responses:
        "201":
          description: Created; the caller is the owner.
          content:
            {
              application/json:
                {
                  schema:
                    {
                      type: object,
                      properties:
                        {
                          id: { $ref: "#/components/schemas/Id" },
                          name: { type: string },
                          default_project_id:
                            { $ref: "#/components/schemas/Id" },
                          plan: { $ref: "#/components/schemas/Plan" },
                        },
                    },
                },
            }

  /workspaces/{id}:
    parameters:
      [
        {
          name: id,
          in: path,
          required: true,
          schema: { $ref: "#/components/schemas/Id" },
        },
      ]
    get:
      tags: [Workspaces]
      summary: Workspace detail
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          {
            description: Detail,
            content:
              {
                application/json:
                  { schema: { $ref: "#/components/schemas/WorkspaceSummary" } },
              },
          }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Workspaces]
      summary: Rename or change the region
      security: [{ sessionCookie: [] }]
      requestBody:
        content:
          {
            application/json:
              {
                schema:
                  {
                    type: object,
                    properties:
                      { name: { type: string }, region: { type: string } },
                  },
              },
          }
      responses:
        "200":
          {
            description: Updated,
            content:
              {
                application/json:
                  { schema: { $ref: "#/components/schemas/WorkspaceSummary" } },
              },
          }
    delete:
      tags: [Workspaces]
      summary: Soft-delete the workspace
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          {
            description: Deleted,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            id: { $ref: "#/components/schemas/Id" },
                            status: { type: string, const: deleted },
                          },
                      },
                  },
              },
          }

  /projects:
    post:
      tags: [Projects]
      summary: Create a project under a workspace
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, name, environment]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                name: { type: string }
                environment:
                  { type: string, enum: [production, staging, development] }
      responses:
        "201":
          {
            description: Created,
            content: { application/json: { schema: { type: object } } },
          }
    get:
      tags: [Projects]
      summary: List active projects in a workspace
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200":
          description: Active projects, with member and non-revoked key counts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { $ref: "#/components/schemas/Id" }
                        name: { type: string }
                        description: { type: [string, "null"] }
                        environment:
                          {
                            type: string,
                            enum: [production, staging, development],
                          }
                        status: { type: string, enum: [active, deleted] }
                        created_at: { $ref: "#/components/schemas/Timestamp" }
                        members_count: { type: integer }
                        api_keys_count: { type: integer }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api-keys:
    post:
      tags: [API Keys]
      summary: Provision an API key (secret returned exactly once)
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project_id, name, scopes]
              properties:
                project_id: { $ref: "#/components/schemas/Id" }
                name: { type: string }
                scopes: { type: array, items: { type: string } }
                live: { type: boolean }
      responses:
        "201":
          {
            description: Created — the `key` value is shown exactly once.,
            content:
              {
                application/json:
                  { schema: { $ref: "#/components/schemas/ApiKeyCreated" } },
              },
          }

  /notifications/preferences:
    get:
      tags: [Notifications]
      summary: The authenticated user's email notification preferences
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: Current preferences (defaults applied if never set).
          content:
            {
              application/json:
                {
                  schema:
                    { $ref: "#/components/schemas/NotificationPreferences" },
                },
            }
        "401": { $ref: "#/components/responses/Unauthorized" }
    patch:
      tags: [Notifications]
      summary: Update one or more notification preferences
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          {
            application/json:
              {
                schema:
                  { $ref: "#/components/schemas/NotificationPreferences" },
              },
          }
      responses:
        "200":
          description: The full, updated preference set.
          content:
            {
              application/json:
                {
                  schema:
                    { $ref: "#/components/schemas/NotificationPreferences" },
                },
            }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /providers/catalog:
    get:
      tags: [Providers]
      summary: The supported provider types and their credential fields
      responses:
        "200":
          {
            description: Catalog,
            content: { application/json: { schema: { type: object } } },
          }

  /providers:
    get:
      tags: [Providers]
      summary: List provider configurations in a workspace
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200":
          {
            description: Configurations (no secrets),
            content: { application/json: { schema: { type: object } } },
          }
    post:
      tags: [Providers]
      summary: Connect a provider (credentials are KMS-envelope-encrypted at rest)
      description: Optional `overrides` (email providers only, otherwise 400) set the provider's own sender, tracking and headers. See `ProviderOverrides`.
      security: [{ sessionCookie: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                overrides: { $ref: "#/components/schemas/ProviderOverrides" }
      responses:
        "201":
          {
            description: Connected,
            content: { application/json: { schema: { type: object } } },
          }

  /providers/{id}:
    parameters:
      [
        {
          name: id,
          in: path,
          required: true,
          schema: { $ref: "#/components/schemas/Id" },
        },
      ]
    get:
      tags: [Providers]
      summary: One provider configuration (no secrets)
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          {
            description: Provider configuration.,
            content:
              {
                application/json:
                  { schema: { $ref: "#/components/schemas/Provider" } },
              },
          }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Providers]
      summary: Update a provider (display name, config, routing, status, credentials or project)
      description: Only the supplied fields change. When `credentials` is present it is re-encrypted and replaces the stored secret wholesale. `project_id` moves the provider to another project in the same workspace, or to the whole workspace when null; the managed email provider always stays on the whole workspace. A provider attached to a project is used only for that project's sends (a workspace-wide one serves every project), so a move takes effect for sends still queued or scheduled. A move is audited as `provider.project_changed`.
      security: [{ sessionCookie: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                display_name: { type: string, minLength: 1, maxLength: 100 }
                config: { type: object, additionalProperties: true }
                credentials:
                  {
                    type: object,
                    additionalProperties: true,
                    description: "Provider secret fields; replaces the stored credentials.",
                  }
                overrides:
                  allOf: [{ $ref: "#/components/schemas/ProviderOverrides" }]
                  description: >
                    Replaces the whole block, so a key left out is unset; `{}` or `null` returns the provider to
                    inheriting everything from the project. Email providers only. A change is audited as
                    `provider.overrides_changed` with the previous and new block.
                priority: { type: integer }
                weight: { type: integer }
                status: { type: string, enum: [active, disabled] }
                project_id:
                  {
                    type: [string, "null"],
                    description: "The project to attach the provider to, or null for every project in the workspace. Omit to leave it unchanged.",
                  }
      responses:
        "200":
          {
            description: Updated,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            id: { $ref: "#/components/schemas/Id" },
                            status: { type: string, const: updated },
                            project_id: { type: [string, "null"] },
                          },
                      },
                  },
              },
          }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Providers]
      summary: Disconnect (delete) a provider configuration
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          {
            description: Disconnected,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          { status: { type: string, const: disconnected } },
                      },
                  },
              },
          }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /providers/{id}/test:
    post:
      tags: [Providers]
      summary: Verify a provider's stored credentials
      description: Decrypts the stored credentials and, when a Send API probe is configured, performs a live reachability check. Result is persisted to `last_test_at` / `last_test_result`.
      security: [{ sessionCookie: [] }]
      parameters:
        [
          {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          },
        ]
      responses:
        "200":
          {
            description: Test result.,
            content:
              {
                application/json:
                  {
                    schema: { $ref: "#/components/schemas/ProviderTestResult" },
                  },
              },
          }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /providers/{id}/send-test:
    post:
      tags: [Providers]
      summary: Send a real test message through this provider
      description: |
        Email providers send a branded message to the signed-in user's own email using the project's default sender; no body is needed.
        SMS providers send a short fixed text to the phone number in `to` (international format, e.g. `+2348012345678`). The text is fixed. Each provider accepts one test SMS every 30 seconds and a workspace 25 a day, shared across replicas (`429 rate_limit_exceeded` otherwise; `503` if the limiter is unavailable, since no test is sent without it). Each is recorded in the audit log as `provider.test_sms_sent` with the number masked. The MessagePipe managed SMS provider is refused.
        Requires a live Send API. Result is persisted to `last_test_at` / `last_test_result`.
      security: [{ sessionCookie: [] }]
      parameters:
        [
          {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          },
        ]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                to:
                  type: string
                  description: Recipient phone number, international format. Required for SMS providers, ignored for email.
                  example: "+2348012345678"
      responses:
        "200":
          {
            description: Send result.,
            content:
              {
                application/json:
                  {
                    schema: { $ref: "#/components/schemas/ProviderTestResult" },
                  },
              },
          }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /providers/{id}/sender-ids:
    get:
      tags: [Providers]
      summary: List the sender IDs registered with an SMS provider
      description: |
        For SMS providers that make the customer register the name a message goes out under (Termii). Calls the provider live. A provider with no registration step answers `supported: false`. Requires a live Send API.
      security: [{ sessionCookie: [] }]
      parameters:
        [
          {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          },
        ]
      responses:
        "200":
          description: The registered sender IDs, or the reason they could not be read (`ok: false`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SenderIdResult" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Providers]
      summary: Request a new sender ID with an SMS provider
      description: |
        Files a sender ID registration (Termii). The provider reviews it by hand, so the new ID is pending until approved. `detail` carries the provider's confirmation.
      security: [{ sessionCookie: [] }]
      parameters:
        [
          {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          },
        ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sender_id, company, use_case]
              properties:
                sender_id:
                  type: string
                  description: 3 to 11 letters and digits.
                  example: AcmeAlerts
                company:
                  type: string
                  maxLength: 200
                  example: Acme Ltd
                use_case:
                  type: string
                  maxLength: 500
                  description: A sample of the messages that will be sent under the ID.
                  example: Your Acme verification code is 123456
      responses:
        "200":
          description: The provider's answer.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SenderIdResult" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /providers/{id}/health:
    get:
      tags: [Providers]
      summary: Last known health status of a provider
      security: [{ sessionCookie: [] }]
      parameters:
        [
          {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          },
        ]
      responses:
        "200":
          description: Health snapshot.
          content:
            {
              application/json:
                {
                  schema:
                    {
                      type: object,
                      properties:
                        {
                          health_status:
                            {
                              type: string,
                              enum: [unknown, healthy, degraded, down],
                            },
                          last_health_check_at:
                            { type: [string, "null"], format: date-time },
                        },
                    },
                },
            }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /providers/{id}/stats:
    get:
      tags: [Providers]
      summary: Send success/failure counts for a provider over a time range
      security: [{ sessionCookie: [] }]
      parameters:
        - {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          }
        - {
            name: range,
            in: query,
            required: false,
            schema: { type: string, default: "7d" },
          }
      responses:
        "200":
          description: Aggregated send stats.
          content:
            application/json:
              schema:
                type: object
                properties:
                  provider_id: { $ref: "#/components/schemas/Id" }
                  range: { type: string }
                  total: { type: integer }
                  succeeded: { type: integer }
                  failed: { type: integer }
                  success_rate: { type: [number, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /providers/routing:
    patch:
      tags: [Providers]
      summary: Set provider priority and weight for failover routing
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, providers]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                providers:
                  type: array
                  items:
                    type: object
                    required: [id]
                    properties:
                      id: { $ref: "#/components/schemas/Id" }
                      priority: { type: integer }
                      weight: { type: integer }
      responses:
        "200":
          {
            description: Updated,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            status: { type: string, const: updated },
                            count: { type: integer },
                          },
                      },
                  },
              },
          }

  /templates:
    post:
      tags: [Templates]
      summary: Create a template
      security: [{ sessionCookie: [] }]
      responses:
        "201":
          {
            description: Created,
            content: { application/json: { schema: { type: object } } },
          }

  /templates/{id}/restore-version/{version}:
    post:
      tags: [Templates]
      summary: Restore a previous version into a new draft
      security: [{ sessionCookie: [] }]
      parameters:
        - {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          }
        - { name: version, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: "A new draft cloned from the chosen version."
          content:
            {
              application/json:
                {
                  schema:
                    {
                      type: object,
                      properties:
                        {
                          id: { $ref: "#/components/schemas/Id" },
                          version: { type: integer },
                          state: { type: string, const: draft },
                          restored_from: { type: integer },
                        },
                    },
                },
            }
        "404": { $ref: "#/components/responses/NotFound" }

  /templates/import-html:
    post:
      tags: [Templates]
      summary: Create an email template from raw HTML
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project_id, name, html]
              properties:
                project_id: { $ref: "#/components/schemas/Id" }
                name: { type: string }
                html: { type: string }
                subject: { type: string }
      responses:
        "201":
          description: "Template created; variables were extracted from the HTML."
          content:
            {
              application/json:
                {
                  schema:
                    {
                      type: object,
                      properties:
                        {
                          id: { $ref: "#/components/schemas/Id" },
                          name: { type: string },
                          channel: { type: string, const: email },
                          state: { type: string, const: draft },
                          version: { type: integer },
                          variables_schema:
                            { type: object, additionalProperties: true },
                        },
                    },
                },
            }
        "400": { $ref: "#/components/responses/ValidationFailed" }

  /webhooks:
    post:
      tags: [Webhooks]
      summary: Register a webhook endpoint
      security: [{ sessionCookie: [] }]
      responses:
        "201":
          {
            description: Registered,
            content: { application/json: { schema: { type: object } } },
          }
    get:
      tags: [Webhooks]
      summary: List webhook endpoints in a workspace
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200":
          description: Endpoints (no signing secrets), newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { $ref: "#/components/schemas/Id" }
                        project_id: { type: [string, "null"] }
                        url: { type: string, format: uri }
                        description: { type: [string, "null"] }
                        events:
                          {
                            type: array,
                            items:
                              { $ref: "#/components/schemas/WebhookEventType" },
                          }
                        status: { $ref: "#/components/schemas/WebhookStatus" }
                        consecutive_failures: { type: integer }
                        last_success_at:
                          { type: [string, "null"], format: date-time }
                        last_attempt_at:
                          { type: [string, "null"], format: date-time }
                        created_at: { $ref: "#/components/schemas/Timestamp" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /webhooks/{id}/verify:
    post:
      tags: [Webhooks]
      summary: Verify ownership of a webhook endpoint
      security: [{ sessionCookie: [] }]
      parameters:
        [
          {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          },
        ]
      responses:
        "200":
          {
            description: "Verification result.",
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            id: { $ref: "#/components/schemas/Id" },
                            status:
                              {
                                type: string,
                                enum: [active, failed_verification],
                              },
                          },
                      },
                  },
              },
          }
        "404": { $ref: "#/components/responses/NotFound" }

  /webhooks/{id}/test:
    post:
      tags: [Webhooks]
      summary: Send a test event to a webhook endpoint
      security: [{ sessionCookie: [] }]
      parameters:
        [
          {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          },
        ]
      responses:
        "200":
          {
            description: "Delivery result.",
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            delivered: { type: boolean },
                            response_code: { type: integer },
                          },
                      },
                  },
              },
          }
        "404": { $ref: "#/components/responses/NotFound" }

  /billing/subscription:
    get:
      tags: [Billing]
      summary: Current subscription, quotas and billing period
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200":
          {
            description: Subscription,
            content:
              {
                application/json:
                  { schema: { $ref: "#/components/schemas/Subscription" } },
              },
          }
    post:
      tags: [Billing]
      summary: Change plan (applies immediately, up to two in-period changes)
      description: >-
        Upgrades and downgrades apply immediately. A downgrade to a cheaper paid plan swaps the price
        now and prorates the monthly credits (the customer keeps the smaller of what is left and the
        new plan's allowance for the rest of the period), with account credit sized by how much of the
        allowance is unused. Subscribing from Free opens a new billing period. In-period moves between
        plans are limited to two per billing period; a further change is accepted but recorded in
        `next_plan` and applied at the renewal (the response shows `plan` unchanged and `next_plan`).
        Asking for the current plan withdraws a waiting change. Moving to Free is never limited.
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          {
            application/json:
              {
                schema:
                  {
                    type: object,
                    required: [workspace_id, plan],
                    properties:
                      {
                        workspace_id: { $ref: "#/components/schemas/Id" },
                        plan: { $ref: "#/components/schemas/Plan" },
                      },
                  },
              },
          }
      responses:
        "200":
          {
            description: Subscription,
            content:
              {
                application/json:
                  { schema: { $ref: "#/components/schemas/Subscription" } },
              },
          }
    delete:
      tags: [Billing]
      summary: Cancel now and move to Free
      description: >-
        Takes effect immediately: the Stripe subscription ends with no refund, the workspace moves to
        Free, and the monthly credits are prorated like any downgrade. Safe to repeat. Never limited by
        the in-period plan change cap.
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200":
          {
            description: The subscription, now on the Free plan,
            content:
              {
                application/json:
                  { schema: { $ref: "#/components/schemas/Subscription" } },
              },
          }

  /billing/usage:
    get:
      tags: [Billing]
      summary: Current-period consumption per channel
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200":
          {
            description: Usage,
            content: { application/json: { schema: { type: object } } },
          }

  /projects/{projectId}/ses-tenants:
    get:
      tags: [Managed email]
      summary: List the managed-email (Amazon SES) tenants of a project
      description: "One tenant per sender domain per project; a workspace may have several. Each carries the DNS records the customer must publish and the state of the domain identity."
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/ProjectIdPath" }]
      responses:
        "200":
          description: "{data: Tenant[]}"
          content: { application/json: { schema: { type: object } } }
        "404": { description: Project not found }
    post:
      tags: [Managed email]
      summary: Add a sender domain (creates the SES tenant, identity and configuration set)
      description: "Claims the domain for this workspace (a domain belongs to one workspace at a time), then returns the DNS records to publish. Refused with managed_email_not_configured when the platform has no SES account configured, and with a conflict when another workspace holds the domain. Capped per workspace and per day."
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/ProjectIdPath" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain]
              properties:
                domain: { type: string, example: mail.example.com }
      responses:
        "201":
          description: Created
          content: { application/json: { schema: { type: object } } }
        "400": { description: Invalid or reserved domain }
        "409": { description: Another workspace holds the domain }
        "429": { description: Tenant or daily creation cap reached }
        "503": { description: managed_email_not_configured }

  /projects/{projectId}/ses-tenants/{id}:
    get:
      tags: [Managed email]
      summary: One tenant, with its DNS records and status
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/ProjectIdPath" }
        - { name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }
      responses:
        "200":
          description: Tenant
          content: { application/json: { schema: { type: object } } }
        "404": { description: Not found }
    delete:
      tags: [Managed email]
      summary: Remove a tenant and release its SES resources
      description: "The domain identity is deleted only when no other tenant of the workspace still uses the domain."
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/ProjectIdPath" }
        - { name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }
      responses:
        "204": { description: Removed }
        "404": { description: Not found }

  /projects/{projectId}/ses-tenants/{id}/refresh:
    post:
      tags: [Managed email]
      summary: Re-read the tenant's state from SES now
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/ProjectIdPath" }
        - { name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }
      responses:
        "200":
          description: Tenant (status is active only when the identity is verified and SES allows sending)
          content: { application/json: { schema: { type: object } } }
        "404": { description: Not found }

  /workspaces/{workspaceId}/dns-connections:
    get:
      tags: [Managed email]
      summary: List the workspace's DNS-provider connections
      description: "Connections used to publish managed-email DNS records automatically. A connection is the workspace default (project_id null) or limited to one project, which is tried first. The credential is never returned. Any member may list."
      security: [{ sessionCookie: [] }]
      parameters: [{ name: workspaceId, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      responses:
        "200":
          description: "{data: Connection[]}; each has id, provider, display_name, project_id, status (active|invalid), status_reason, zones, last_verified_at, created_at"
          content: { application/json: { schema: { type: object } } }
        "404": { description: Workspace not found }
    post:
      tags: [Managed email]
      summary: Connect a DNS provider (Cloudflare)
      description: "Verifies the token against Cloudflare and stores it encrypted. The token needs Zone:Zone:Read and Zone:DNS:Edit on the zones concerned and nothing else. At most 10 per workspace. Refused during impersonation. Owner, admin and developer only."
      security: [{ sessionCookie: [] }]
      parameters: [{ name: workspaceId, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [provider, api_token, display_name]
              properties:
                provider: { type: string, enum: [cloudflare] }
                api_token: { type: string, description: "Write-only. Never returned, logged or audited." }
                display_name: { type: string, maxLength: 100 }
                project_id: { type: string, description: "Limit the connection to one project of this workspace; omit for the workspace default." }
      responses:
        "201":
          description: Connection (without the credential)
          content: { application/json: { schema: { type: object } } }
        "400": { description: "validation_failed: not a token, rejected by Cloudflare, or it sees no zones" }
        "403": { description: Role not permitted, or impersonating }
        "404": { description: Workspace or project not found }
        "409": { description: "dns_connection_limit_reached" }
        "429": { description: Too many DNS requests this hour }
        "502": { description: "dns_provider_error; details.reason says why" }

  /workspaces/{workspaceId}/dns-connections/{id}/verify:
    post:
      tags: [Managed email]
      summary: Re-check a stored DNS credential
      description: "A credential Cloudflare now rejects is marked invalid (status_reason token_rejected or missing_permission) and reported with 200; an outage is 502. A connection restored from a workspace export is `invalid` with status_reason `needs_verification` until this call succeeds."
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: workspaceId, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }
        - { name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }
      responses:
        "200":
          description: Connection
          content: { application/json: { schema: { type: object } } }
        "403": { description: Role not permitted, or impersonating }
        "404": { description: Not found }
        "429": { description: Too many DNS requests this hour }
        "502": { description: dns_provider_error }

  /workspaces/{workspaceId}/dns-connections/{id}:
    delete:
      tags: [Managed email]
      summary: Remove a DNS connection
      description: "Records already published stay in DNS. Removing the connection that made them means they can no longer be removed automatically."
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: workspaceId, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }
        - { name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }
      responses:
        "204": { description: Removed }
        "403": { description: Role not permitted, or impersonating }
        "404": { description: Not found }

  /projects/{projectId}/ses-tenants/{id}/dns/plan:
    post:
      tags: [Managed email]
      summary: Read the customer's DNS and plan the tenant's records (changes nothing)
      description: "Resolves the connection that serves the domain's zone (the project's own connections first, then the workspace's), reads what exists at each name, and returns one item per record: state create, present, compatible, conflict or blocked. A conflict lists the actions allowed (keep, update, overwrite) and a fingerprint to send back with the choice. Errors: dns_connection_required, dns_connection_invalid, dns_zone_not_found, ses_tenant_deleting, ses_tenant_incomplete (409), dns_provider_error (502)."
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/ProjectIdPath" }
        - { name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                connection_id: { type: string, description: "Use this connection instead of resolving one." }
      responses:
        "200":
          description: "{connection, zone, items: PlanItem[]}"
          content: { application/json: { schema: { type: object } } }
        "403": { description: Role not permitted, or impersonating }
        "404": { description: Not found }
        "409": { description: See description }
        "429": { description: Too many DNS requests this hour }
        "502": { description: dns_provider_error }

  /projects/{projectId}/ses-tenants/{id}/dns/apply:
    post:
      tags: [Managed email]
      summary: Publish the tenant's DNS records
      description: "Re-plans against the live DNS and applies it. Records that are missing are created. A conflicting record is changed only when choices names it with an allowed action and the fingerprint from the plan; with no choice, or if the record changed since the plan, it is left alone (outcome kept or stale). Nothing is ever deleted except by an explicit overwrite of a single record. Every change is written to a ledger so it can be undone. Stops early (aborted) if the provider rejects the credential or is unreachable."
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/ProjectIdPath" }
        - { name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                connection_id: { type: string }
                choices:
                  type: object
                  description: "Plan item key -> choice. At most 50."
                  additionalProperties:
                    type: object
                    required: [action, fingerprint]
                    properties:
                      action: { type: string, enum: [keep, update, overwrite] }
                      fingerprint: { type: string, description: "From the plan item, 32 hex characters." }
      responses:
        "200":
          description: "{results: [{key, purpose, type, name, outcome, reason?}], aborted?, connection, zone}. Outcomes: created, added, updated, unchanged, kept, blocked, stale, failed, skipped."
          content: { application/json: { schema: { type: object } } }
        "400": { description: Malformed choices }
        "403": { description: Role not permitted, or impersonating }
        "404": { description: Not found }
        "409": { description: "Same as plan, or dns_operation_in_progress: another apply or cleanup of this domain is still running" }
        "429": { description: Too many DNS requests this hour }
        "502": { description: dns_provider_error }

  /projects/{projectId}/ses-tenants/{id}/dns/managed:
    get:
      tags: [Managed email]
      summary: The DNS records MessagePipe has published for this domain
      description: "From the ledger alone; no call to the DNS provider. shared is true when another tenant of the workspace still uses the domain, in which case removing this tenant leaves the records."
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/ProjectIdPath" }
        - { name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }
      responses:
        "200":
          description: "{shared, data: [{purpose, type, name, value, action (created|updated), created_at}]}"
          content: { application/json: { schema: { type: object } } }
        "404": { description: Not found }

  /projects/{projectId}/ses-tenants/{id}/dns/cleanup:
    post:
      tags: [Managed email]
      summary: Take back the DNS records MessagePipe published for this domain
      description: "Deletes a record only if MessagePipe created it and it still has exactly the content MessagePipe gave it; restores a record MessagePipe changed to its previous content only if it still has the content MessagePipe wrote. A record someone else has edited since is left alone. Does nothing (skipped shared) while another tenant of the workspace still uses the domain. Call it before DELETE of the tenant; the delete itself never touches DNS."
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/ProjectIdPath" }
        - { name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }
      responses:
        "200":
          description: "{skipped?, aborted?, results: [{purpose, type, name, outcome, reason?}]}. Outcomes: removed, restored, already_gone, left_changed, failed, skipped."
          content: { application/json: { schema: { type: object } } }
        "403": { description: Role not permitted, or impersonating }
        "404": { description: Not found }
        "409": { description: "dns_connection_required, dns_connection_invalid or dns_zone_not_found (the records stay and must be removed by hand), or dns_operation_in_progress" }
        "429": { description: Too many DNS requests this hour }
        "502": { description: dns_provider_error }

  /billing/credits:
    get:
      tags: [Billing]
      summary: Live credit balances (Redis fast path with Postgres fallback)
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200":
          description: Balances.
          content:
            application/json:
              schema:
                type: object
                properties:
                  channels:
                    {
                      type: array,
                      items: { $ref: "#/components/schemas/CreditBalance" },
                    }

  /billing/credits/topup:
    post:
      tags: [Billing]
      summary: Buy non-expiring credits for one channel
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, channel, amount]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                channel: { $ref: "#/components/schemas/Channel" }
                amount: { type: integer, minimum: 1000 }
                idempotency_key:
                  {
                    type: string,
                    description: "Optional client-supplied key; reuse across retries of the same purchase to avoid a double charge.",
                  }
      responses:
        "201":
          {
            description: Granted (local mode) — credits and ledger row,
            content: { application/json: { schema: { type: object } } },
          }
        "202":
          {
            description: Payment required (Stripe mode) — returns client_secret,
            content: { application/json: { schema: { type: object } } },
          }

  /billing/invoices:
    get:
      tags: [Billing]
      summary: Invoice history
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200":
          {
            description: Invoices,
            content: { application/json: { schema: { type: object } } },
          }

  /billing/invoices/{id}:
    get:
      tags: [Billing]
      summary: One invoice with line items
      security: [{ sessionCookie: [] }]
      parameters:
        - {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          }
        - { $ref: "#/components/parameters/WorkspaceIdQuery" }
      responses:
        "200":
          {
            description: Invoice,
            content: { application/json: { schema: { type: object } } },
          }
        "404": { $ref: "#/components/responses/NotFound" }

  /billing/payment-method:
    get:
      tags: [Billing]
      summary: Stored card details (Stripe mode) or local-mode marker
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200":
          {
            description: Payment method,
            content: { application/json: { schema: { type: object } } },
          }
    post:
      tags: [Billing]
      summary: Open a Stripe SetupIntent to attach a card
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          {
            description: SetupIntent,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties: { client_secret: { type: string } },
                      },
                  },
              },
          }
        "503":
          {
            description: Payment methods require Stripe; not configured in this environment.,
          }

  /billing/webhooks/stripe:
    post:
      tags: [Billing]
      summary: Stripe webhook (public, signature-authed; raw body required)
      description: |
        The request body is verified with the configured webhook signing secret
        (`Stripe-Signature` header). Handled events: `payment_intent.succeeded`
        (top-up grant), `invoice.*` (upsert invoice), `customer.subscription.*`
        (sync status/period). All handlers are idempotent.
      requestBody:
        required: true
        content: { application/json: { schema: { type: object } } }
      responses:
        "200": { description: Acknowledged. }
        "400": { description: Missing or invalid Stripe-Signature header. }
        "503": { description: Stripe is not configured in this environment. }

  /internal/billing/auto-recharge:
    post:
      tags: [Billing]
      summary: Trigger an automatic credit top-up (internal; service-to-service only)
      description: |
        Called by background-jobs when a workspace's credit balance drops below
        its configured auto-recharge threshold. Authenticated via the
        `X-Internal-Token` header (compared against `INTERNAL_API_SECRET`), not
        a user session — never call this from a browser or with a user's
        credentials. Charges the workspace's default payment method and grants
        the top-up on success; safe to retry with the same idempotency_key.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, channel, amount, idempotency_key]
              properties:
                workspace_id: { type: string }
                channel: { type: string, enum: [email, sms, push, chat] }
                amount: { type: integer, minimum: 1000 }
                idempotency_key: { type: string }
      responses:
        "200":
          {
            description: Charge attempted; see `status`,
            or `skipped` if no payment method is on file.,
          }
        "401": { description: Missing or invalid X-Internal-Token. }
        "500":
          {
            description: Charge succeeded but the credit grant failed; the webhook will retry it.,
          }
        "503": { description: Stripe is not configured in this environment. }

  /internal/plans/{id}/stripe-price:
    post:
      tags: [Billing]
      summary: Make sure Stripe has a price for a plan's console price (internal; service-to-service only)
      description: |
        Called by the admin API right after an operator changes a paid plan's price. The price set in the admin
        console takes precedence over Stripe: if the Stripe price the plan points at is missing, inactive, or differs
        from `plans.price_cents` in amount, currency (USD) or interval (monthly), a NEW Stripe price is created for
        exactly the console amount (on the old price's product when it still exists), stored in
        `plans.stripe_price_id`, and added to the `plan_stripe_prices` history. The same step runs lazily when a
        customer buys, so this call only moves it earlier, while an operator is watching. Idempotent: with nothing to
        change it answers `unchanged` and writes nothing; concurrent calls produce one price.

        Existing subscriptions are never touched: subscribers keep the Stripe price they signed up on until they
        change plan or cancel, and webhooks naming an older price still map to the plan.

        Authenticated via the `X-Internal-Token` header (compared against `INTERNAL_API_SECRET`), not a user session.
        A Stripe error answers 500 and changes nothing.
      security: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, enum: [free, special, pro, scale, enterprise] } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                requested_by: { type: string, description: "Who asked (an operator id); recorded as the price's `created_by` in the history." }
      responses:
        "200":
          description: |
            `{plan_id, status, price_cents, currency, stripe_price_id, previous_stripe_price_id}` with `status` one of
            `unchanged`, `created`, `adopted` (an existing Stripe price with the same amount was found by its lookup
            key). `skipped` (Stripe is not configured on mgmt-api) and `not_applicable` (a plan with no charge) carry a
            `reason` instead.
        "400": { description: Invalid `requested_by`. }
        "401": { description: Missing or invalid X-Internal-Token. }
        "404": { description: Unknown plan. }
        "500": { description: Stripe could not check or create the price; nothing was changed or charged. }

  /internal/ses/sync:
    post:
      tags: [Managed email]
      summary: Poll managed email tenants and finish stalled teardowns (internal; service-to-service only)
      description: |
        Called by background-jobs every few minutes. Authenticated via the
        `X-Internal-Token` header (compared in constant time against
        `INTERNAL_API_SECRET`), not a user session — never call this from a
        browser. Re-polls Amazon SES for the tenants that were checked least
        recently (so a pause by SES or a freshly verified domain shows up
        without anyone pressing "Check status"), marks tenants that have waited
        14 days for their DNS records `failed` / `dns_not_verified` (they are
        kept, not deleted), and retries teardowns stuck in `deleting` for more
        than 15 minutes. One tenant's failure does not stop the run; a throttled
        or refused SES call does (`stopped_early`). Safe to repeat.
      security: []
      responses:
        "200":
          description: |
            Counts for the run, or `{ "already_running": true }` when another run holds the lock.
          content:
            application/json:
              schema:
                type: object
                properties:
                  checked: { type: integer, description: Tenants polled successfully. }
                  changed: { type: integer, description: Of those, how many changed status or identity state. }
                  failed: { type: integer, description: Tenants whose poll failed. }
                  stopped_early: { type: boolean }
                  skipped: { type: integer }
                  expired_pending: { type: integer }
                  teardowns_completed: { type: integer }
                  teardowns_failed: { type: integer }
                  already_running:
                    { type: boolean, description: "Only when another run holds the lock; nothing was done." }
        "401": { description: Missing or invalid X-Internal-Token. }
        "503": { description: Managed email is not configured (`managed_email_not_configured`). }

  /internal/ses/reap:
    post:
      tags: [Managed email]
      summary: Remove SES tenants whose database row is gone (internal; service-to-service only)
      description: |
        Called by background-jobs on a long interval, and only when
        `SES_REAP_ENABLED` is set. Authenticated via `X-Internal-Token` like
        `/internal/ses/sync`. Finds Amazon SES tenants that carry MessagePipe's
        `messagepipe:managed` tag, are named by MessagePipe's scheme, are older
        than 24 hours and have no `ses_tenants` row (for example after a project
        or workspace was hard-deleted), and removes them with their
        configuration set and, when no claim, row or other tenant uses it, their
        domain identity. At most 20 tenants per run. **A request that does not
        say `dry_run: false` only reports what it would delete.**
      security: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                dry_run:
                  {
                    type: boolean,
                    default: true,
                    description: "false deletes; omitted or true only reports.",
                  }
      responses:
        "200":
          description: |
            What was (or, in a dry run, would be) removed, with `aborted` set when the run refused to delete anything
            (`too_many_orphans`, `database_knows_none_of_the_tenants`, `throttled`, `access_denied`), or `{ "already_running": true }`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  dry_run: { type: boolean }
                  aborted: { type: string, nullable: true }
                  tenants_listed: { type: integer }
                  scheme_tenants: { type: integer }
                  orphans: { type: integer }
                  deleted: { type: integer }
                  identities_deleted: { type: integer }
                  configuration_sets_deleted: { type: integer }
                  failed: { type: integer }
                  capped: { type: boolean }
                  skipped: { type: object, additionalProperties: { type: integer } }
                  actions:
                    type: array
                    items:
                      type: object
                      properties:
                        tenant: { type: string }
                        configuration_set: { type: string, nullable: true }
                        identity: { type: string, nullable: true }
                        outcome: { type: string, enum: [deleted, would_delete, failed] }
        "400": { description: "`dry_run` is not a boolean." }
        "401": { description: Missing or invalid X-Internal-Token. }
        "502": { description: SES could not be listed (`managed_email_provider_error`); nothing was deleted. }
        "503": { description: Managed email is not configured (`managed_email_not_configured`). }

  /invites/{token}:
    get:
      tags: [Auth]
      summary: Look up a pending invite by its token (public)
      security: []
      parameters:
        [{ name: token, in: path, required: true, schema: { type: string } }]
      responses:
        "200":
          description: The invite's target email, role and workspace name.
          content:
            {
              application/json:
                {
                  schema:
                    {
                      type: object,
                      properties:
                        {
                          email: { $ref: "#/components/schemas/Email" },
                          role:
                            {
                              type: string,
                              enum:
                                [
                                  workspace_owner,
                                  workspace_admin,
                                  developer,
                                  marketer,
                                  viewer,
                                ],
                            },
                          workspace_name: { type: string },
                        },
                    },
                },
            }
        "404": { $ref: "#/components/responses/NotFound" }
  /auth/impersonation/exchange:
    post:
      tags: [Auth]
      summary: Exchange a one-time impersonation handoff token for a session cookie
      description: Single-use; the token is consumed on lookup.
      security: []
      requestBody:
        required: true
        content:
          {
            application/json:
              {
                schema:
                  {
                    type: object,
                    required: [token],
                    properties:
                      { token: { type: string, minLength: 1, maxLength: 256 } },
                  },
              },
          }
      responses:
        "200":
          {
            description: The signed session cookie value to set for the impersonated user.,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties: { session_cookie: { type: string } },
                      },
                  },
              },
          }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /workspaces/{id}/transfer:
    post:
      tags: [Workspaces]
      summary: Transfer workspace ownership to another active member
      description: Owner-only. The caller is demoted to workspace_admin.
      security: [{ sessionCookie: [] }]
      parameters:
        [
          {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          },
        ]
      requestBody:
        required: true
        content:
          {
            application/json:
              {
                schema:
                  {
                    type: object,
                    required: [new_owner_user_id],
                    properties:
                      {
                        new_owner_user_id: { $ref: "#/components/schemas/Id" },
                      },
                  },
              },
          }
      responses:
        "200":
          {
            description: Ownership transferred,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            status: { type: string, const: transferred },
                            new_owner_user_id:
                              { $ref: "#/components/schemas/Id" },
                          },
                      },
                  },
              },
          }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /workspaces/{id}/switch:
    post:
      tags: [Workspaces]
      summary: Make this workspace the session's current workspace
      description: The caller must be an active member and the workspace must not be deleted. Not available during impersonation.
      security: [{ sessionCookie: [] }]
      parameters:
        [
          {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          },
        ]
      responses:
        "200":
          {
            description: Switched,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            current_workspace: { $ref: "#/components/schemas/Id" },
                          },
                      },
                  },
              },
          }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /workspaces/{id}/members:
    get:
      tags: [Workspaces]
      summary: List members of a workspace
      security: [{ sessionCookie: [] }]
      parameters:
        [
          {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          },
        ]
      responses:
        "200":
          description: Members.
          content:
            {
              application/json:
                {
                  schema:
                    {
                      type: object,
                      properties:
                        {
                          data:
                            {
                              type: array,
                              items: { $ref: "#/components/schemas/Member" },
                            },
                        },
                    },
                },
            }
        "403": { $ref: "#/components/responses/Forbidden" }
  /workspaces/{id}/members/{userId}:
    parameters:
      - {
          name: id,
          in: path,
          required: true,
          schema: { $ref: "#/components/schemas/Id" },
        }
      - {
          name: userId,
          in: path,
          required: true,
          schema: { $ref: "#/components/schemas/Id" },
        }
    patch:
      tags: [Workspaces]
      summary: Change a workspace member's role, or suspend or restore their access
      description: |
        Admin-only. Send a role, a status, or both. The owner role is not assignable here (use the transfer
        flow), and the owner cannot be changed or suspended. `status: suspended` ends the member's access to
        THIS workspace only; their account and other workspaces are untouched, and `active` restores it.
        You cannot suspend yourself. Only the owner can change or suspend an admin.
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                role:
                  {
                    type: string,
                    enum: [workspace_admin, developer, marketer, viewer],
                  }
                status: { type: string, enum: [active, suspended] }
      responses:
        "200":
          {
            description: Updated,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            user_id: { $ref: "#/components/schemas/Id" },
                            role: { type: string },
                            status: { type: string, enum: [active, suspended] },
                          },
                      },
                  },
              },
          }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Workspaces]
      summary: Remove a member from the workspace
      description: Admin-only. The owner cannot be removed.
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          {
            description: Removed,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          { status: { type: string, const: removed } },
                      },
                  },
              },
          }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /workspaces/{id}/invites:
    parameters:
      [
        {
          name: id,
          in: path,
          required: true,
          schema: { $ref: "#/components/schemas/Id" },
        },
      ]
    post:
      tags: [Workspaces]
      summary: Invite a user to the workspace by email
      description: Admin-only. Emails an invite link; the raw token is returned exactly once.
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, role]
              properties:
                email: { $ref: "#/components/schemas/Email" }
                role:
                  {
                    type: string,
                    enum:
                      [
                        workspace_owner,
                        workspace_admin,
                        developer,
                        marketer,
                        viewer,
                      ],
                  }
                personal_note: { type: string }
      responses:
        "201":
          {
            description: "Invite created — token shown exactly once.",
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            id: { $ref: "#/components/schemas/Id" },
                            email: { $ref: "#/components/schemas/Email" },
                            role: { type: string },
                            token: { type: string },
                          },
                      },
                  },
              },
          }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
    get:
      tags: [Workspaces]
      summary: List pending invites for the workspace
      description: Admin-only.
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: Pending (unaccepted, unrevoked) invites.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { $ref: "#/components/schemas/Id" }
                        email: { $ref: "#/components/schemas/Email" }
                        role: { type: string }
                        invited_by: { $ref: "#/components/schemas/Id" }
                        expires_at: { $ref: "#/components/schemas/Timestamp" }
                        created_at: { $ref: "#/components/schemas/Timestamp" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /workspaces/{id}/members/{userId}/password-reset:
    post:
      tags: [Workspaces]
      summary: Email a member a password reset link
      description: |
        Admin-only. The link goes to the member's own address, never to the admin. Not available for yourself,
        for the owner, for members whose account is not active, or while impersonating. Only the owner can do it
        for an admin. One per couple of minutes per member.
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }
        - { name: userId, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }
      responses:
        "200":
          {
            description: Sent,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties: { status: { type: string, const: sent } },
                      },
                  },
              },
          }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { description: Sent too recently; try again shortly. }
  /workspaces/{id}/members/{userId}/mfa-reset:
    post:
      tags: [Workspaces]
      summary: Remove a member's two-factor authentication
      description: |
        Admin-only. Removes the member's authenticator and recovery codes at once, signs them out everywhere and
        emails them. Two-factor authentication belongs to the account, so this applies to every workspace the
        person is in. A reason is required and is recorded in the audit log (critical). Not available for
        yourself, for the owner, for members whose account is not active, or while impersonating; only the owner
        can do it for an admin; 409 if the member has no 2FA or their access to the workspace is suspended. For
        24 hours afterwards, password reset links do not set a new password for that account (409 from
        `POST /auth/password-reset/confirm`), so 2FA removal and an emailed password reset cannot be chained. The same applies the other way: 409 if a password was set through an emailed reset link in the last 24 hours.
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }
        - { name: userId, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason: { type: string, minLength: 3, maxLength: 500 }
      responses:
        "200":
          {
            description: Removed,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            status: { type: string, const: 2fa_removed },
                            sessions_revoked: { type: integer },
                          },
                      },
                  },
              },
          }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /workspaces/{id}/invites/{inviteId}/resend:
    post:
      tags: [Workspaces]
      summary: Resend a pending workspace invite
      description: |
        Admin-only. Issues a new link (the old one stops working) valid for seven days from now, which also
        revives an expired invite, and emails it to the invited address. The token is never returned. One
        resend a minute per invite.
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }
        - { name: inviteId, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }
      responses:
        "200":
          {
            description: Sent,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties: { sent: { type: boolean, const: true } },
                      },
                  },
              },
          }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { description: Sent too recently; try again shortly. }
  /workspaces/{id}/invites/{inviteId}:
    delete:
      tags: [Workspaces]
      summary: Revoke a pending workspace invite
      description: Admin-only. Only invites that have not yet been accepted can be revoked.
      security: [{ sessionCookie: [] }]
      parameters:
        - {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          }
        - {
            name: inviteId,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          }
      responses:
        "200":
          {
            description: Revoked,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          { status: { type: string, const: revoked } },
                      },
                  },
              },
          }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /workspaces/invites/{token}/accept:
    post:
      tags: [Workspaces]
      summary: Accept a workspace invite as the signed-in user
      description: The caller's email must match the invite's target email. Sets the accepted workspace as current.
      security: [{ sessionCookie: [] }]
      parameters:
        [{ name: token, in: path, required: true, schema: { type: string } }]
      responses:
        "200":
          {
            description: Joined,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            status: { type: string, const: accepted },
                            workspace_id: { $ref: "#/components/schemas/Id" },
                            role: { type: string },
                          },
                      },
                  },
              },
          }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /projects/{id}:
    parameters:
      [
        {
          name: id,
          in: path,
          required: true,
          schema: { $ref: "#/components/schemas/Id" },
        },
      ]
    get:
      tags: [Projects]
      summary: Project detail
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: Project detail.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { $ref: "#/components/schemas/Id" }
                  workspace_id: { $ref: "#/components/schemas/Id" }
                  name: { type: string }
                  environment:
                    { type: string, enum: [production, staging, development] }
                  status: { type: string, enum: [active, deleted] }
                  description: { type: [string, "null"] }
                  default_from_name: { type: [string, "null"] }
                  default_from_email: { type: [string, "null"] }
                  default_reply_to: { type: [string, "null"] }
                  sender_domains:
                    type: array
                    items: { type: string }
                    description: Domains a per-message `from` may use, besides the default sender's domain.
                  provider_pins:
                    type: array
                    items: { $ref: "#/components/schemas/Id" }
                    description: Provider configurations pinned to this project. Empty means the project is not pinned.
                  provider_pins_exclusive:
                    type: boolean
                    description: Whether the pinned list is exclusive (the default). See the PATCH operation.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Projects]
      summary: Update project name, description and email defaults
      description: All fields optional; omitted fields keep their current value.
      security: [{ sessionCookie: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: string }
                default_from_name: { type: string }
                default_from_email: { type: string }
                default_reply_to: { type: string }
                sender_domains:
                  type: array
                  maxItems: 20
                  items: { type: string, example: example.com }
                  description: >
                    Domains a Send API `from` may use on this project, in addition to the
                    domain of `default_from_email`. Bare domains only; lowercased and
                    deduplicated. An empty array clears the list. When neither this list
                    nor a default sender is set, `from` is unrestricted. Sending from a
                    domain also needs it verified with the email provider (SPF/DKIM).
                provider_pins:
                  type: array
                  maxItems: 20
                  items: { $ref: "#/components/schemas/Id" }
                  description: >
                    Provider configurations (ids from `/providers`) this project's sends may use,
                    for every API key and template of the project. Each must belong to the project's
                    workspace and be workspace-wide or attached to this project (otherwise 400); the list
                    is deduplicated. An empty array clears it, and an unpinned project routes
                    across every provider it can use. Changes are audited
                    as `project.provider_pins_updated`. See `provider_pins_exclusive`.
                provider_pins_exclusive:
                  type: boolean
                  description: >
                    Defaults to `true`. When `true`, only the pinned providers may take a send:
                    anything else, including a `preferred_provider` or `providers` on the request,
                    is never used, and a send that none of the pinned providers can take (the
                    request named another provider, or the pinned ones are disabled, down or
                    deleted) fails with `failure_code: provider_not_pinned`. When `false`, the pinned
                    providers are tried first in the usual priority, health and weight order and
                    the rest of the workspace's providers remain a fallback. Has no effect while
                    `provider_pins` is empty.
      responses:
        "200":
          {
            description: Updated project.,
            content: { application/json: { schema: { type: object } } },
          }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Projects]
      summary: Soft-delete a project
      description: Blocked with `project_has_recent_activity` if the project has sends within the last 24 hours.
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          {
            description: Project soft-deleted.,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          { status: { type: string, const: soft_deleted } },
                      },
                  },
              },
          }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /projects/{id}/logo:
    get:
      tags: [Brands]
      summary: "The project's default BIMI sender picture, certificate address, and DNS records (any member)"
      description: >
        The default picture is used by the managed-email domains of the project and by the sender domains of its brands that have no picture of
        their own. `domains` lists those domains with the record to publish and their readiness. See GET /brands/{id}/logo for the shape.
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "PictureView", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Brands]
      summary: "Upload the project's default sender picture (developer+)"
      description: "Same body, rules and limits as PUT /brands/{id}/logo; the picture belongs to the project."
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      requestBody:
        required: true
        content:
          image/svg+xml: { schema: { type: string, format: binary } }
      responses:
        "200": { description: "The stored picture", content: { application/json: { schema: { type: object } } } }
        "400": { description: "Not an acceptable SVG (see PUT /brands/{id}/logo)" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "Another upload for this project was in progress" }
        "429": { description: "Too many uploads for this workspace today" }
    patch:
      tags: [Brands]
      summary: "Set or clear the project's certificate address (developer+)"
      description: "Same as PATCH /brands/{id}/logo, for the project's default picture."
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [certificate_url]
              properties:
                certificate_url: { type: [string, "null"], example: "https://example.com/vmc.pem" }
      responses:
        "200": { description: "PictureView", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Brands]
      summary: "Remove the project's default sender picture (developer+)"
      description: >
        A takedown, as for a brand: every stored version of the project's default picture is hard-deleted and all of its addresses answer 404 at
        once. It does not touch the brands' own pictures. Cached copies can outlive the deletion, and the DNS records still point at the old address
        until the customer removes them.
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "204": { description: "Removed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /projects/{id}/members:
    parameters:
      [
        {
          name: id,
          in: path,
          required: true,
          schema: { $ref: "#/components/schemas/Id" },
        },
      ]
    get:
      tags: [Projects]
      summary: List project members
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: Members of the project.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        user_id: { $ref: "#/components/schemas/Id" }
                        role:
                          {
                            type: string,
                            enum:
                              [
                                project_admin,
                                project_developer,
                                project_viewer,
                              ],
                          }
                        added_at: { $ref: "#/components/schemas/Timestamp" }
                        email: { $ref: "#/components/schemas/Email" }
                        full_name: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Projects]
      summary: Add a member to a project
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_id, role]
              properties:
                user_id: { $ref: "#/components/schemas/Id" }
                role:
                  {
                    type: string,
                    enum: [project_admin, project_developer, project_viewer],
                  }
      responses:
        "201":
          {
            description: Member added.,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            project_id: { $ref: "#/components/schemas/Id" },
                            user_id: { $ref: "#/components/schemas/Id" },
                            role: { type: string },
                          },
                      },
                  },
              },
          }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /projects/{id}/members/{userId}:
    parameters:
      - {
          name: id,
          in: path,
          required: true,
          schema: { $ref: "#/components/schemas/Id" },
        }
      - {
          name: userId,
          in: path,
          required: true,
          schema: { $ref: "#/components/schemas/Id" },
        }
    patch:
      tags: [Projects]
      summary: Change a project member's role
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role:
                  {
                    type: string,
                    enum: [project_admin, project_developer, project_viewer],
                  }
      responses:
        "200":
          {
            description: Updated,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            project_id: { $ref: "#/components/schemas/Id" },
                            user_id: { $ref: "#/components/schemas/Id" },
                            role: { type: string },
                          },
                      },
                  },
              },
          }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Projects]
      summary: Remove a member from a project
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          {
            description: Removed,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          { status: { type: string, const: removed } },
                      },
                  },
              },
          }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api-keys/{id}:
    parameters:
      [
        {
          name: id,
          in: path,
          required: true,
          schema: { $ref: "#/components/schemas/Id" },
        },
      ]
    get:
      tags: [API Keys]
      summary: API key detail (no secret)
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: API key metadata; the secret is never returned here.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { $ref: "#/components/schemas/Id" }
                  workspace_id: { $ref: "#/components/schemas/Id" }
                  project_id: { $ref: "#/components/schemas/Id" }
                  name: { type: string }
                  key_prefix: { type: string }
                  key_last4: { type: string }
                  scopes: { type: array, items: { type: string } }
                  last_used_at: { type: [string, "null"], format: date-time }
                  revoked_at: { type: [string, "null"], format: date-time }
                  expires_at: { type: [string, "null"], format: date-time }
                  created_at: { $ref: "#/components/schemas/Timestamp" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [API Keys]
      summary: Replace the scopes on an API key
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scopes]
              properties:
                scopes:
                  {
                    type: array,
                    minItems: 1,
                    items:
                      {
                        type: string,
                        enum:
                          [
                            send:email,
                            send:sms,
                            send:push,
                            send:chat,
                            sends:read,
                            templates:read,
                            analytics:read,
                          ],
                      },
                  }
      responses:
        "200":
          {
            description: Updated scopes.,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            id: { $ref: "#/components/schemas/Id" },
                            scopes: { type: array, items: { type: string } },
                          },
                      },
                  },
              },
          }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api-keys/{id}/rotate:
    post:
      tags: [API Keys]
      summary: Rotate an API key
      description: Mints a replacement key inheriting project, name and scopes, with a 24-hour grace window on the old key. The new secret is shown exactly once.
      security: [{ sessionCookie: [] }]
      parameters:
        [
          {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          },
        ]
      responses:
        "200":
          {
            description: "Replacement key; secret shown exactly once.",
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            id: { $ref: "#/components/schemas/Id" },
                            rotated_from: { $ref: "#/components/schemas/Id" },
                            key_prefix: { type: string },
                            key_last4: { type: string },
                            secret: { type: string },
                          },
                      },
                  },
              },
          }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api-keys/{id}/revoke:
    post:
      tags: [API Keys]
      summary: Revoke an API key
      security: [{ sessionCookie: [] }]
      parameters:
        [
          {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          },
        ]
      requestBody:
        content:
          {
            application/json:
              {
                schema:
                  {
                    type: object,
                    properties:
                      {
                        reason:
                          {
                            type: string,
                            description: "Defaults to manual_revocation.",
                          },
                      },
                  },
              },
          }
      responses:
        "200":
          {
            description: Key revoked.,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            id: { $ref: "#/components/schemas/Id" },
                            status: { type: string, const: revoked },
                          },
                      },
                  },
              },
          }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /templates/{id}:
    parameters:
      [
        {
          name: id,
          in: path,
          required: true,
          schema: { $ref: "#/components/schemas/Id" },
        },
      ]
    get:
      tags: [Templates]
      summary: Fetch a template with its current version's content
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: Template detail; content is the current version's payload.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { $ref: "#/components/schemas/Id" }
                  workspace_id: { $ref: "#/components/schemas/Id" }
                  project_id: { $ref: "#/components/schemas/Id" }
                  name: { type: string }
                  channel: { $ref: "#/components/schemas/Channel" }
                  sub_type: { type: [string, "null"] }
                  state: { type: string, enum: [draft, active, archived] }
                  current_version: { type: integer }
                  variables_schema:
                    {
                      type: array,
                      items: { $ref: "#/components/schemas/VariableSchema" },
                    }
                  updated_at: { $ref: "#/components/schemas/Timestamp" }
                  content: { type: object, additionalProperties: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Templates]
      summary: Save a new draft version (and optionally rename)
      description: Appends a new version from `content`, re-extracts the variable schema, and optionally renames. Does not publish.
      security: [{ sessionCookie: [] }]
      requestBody:
        content:
          {
            application/json:
              {
                schema:
                  {
                    type: object,
                    properties:
                      {
                        content: { type: object, additionalProperties: true },
                        name: { type: string },
                      },
                  },
              },
          }
      responses:
        "200":
          {
            description: New draft version created.,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            id: { $ref: "#/components/schemas/Id" },
                            version: { type: integer },
                            state: { type: string },
                            variables_schema:
                              {
                                type: array,
                                items:
                                  {
                                    $ref: "#/components/schemas/VariableSchema",
                                  },
                              },
                          },
                      },
                  },
              },
          }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
    delete:
      tags: [Templates]
      summary: Soft-delete (archive) a template
      description: Rejected with 409 if the template has sends within the last 24 hours.
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          {
            description: Archived,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          { status: { type: string, const: deleted } },
                      },
                  },
              },
          }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /templates/{id}/versions:
    get:
      tags: [Templates]
      summary: List a template's versions
      security: [{ sessionCookie: [] }]
      parameters:
        [
          {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          },
        ]
      responses:
        "200":
          description: Versions, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        version: { type: integer }
                        published_at:
                          { type: [string, "null"], format: date-time }
                        created_by: { $ref: "#/components/schemas/Id" }
                        created_at: { $ref: "#/components/schemas/Timestamp" }
                  current_version: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /templates/{id}/versions/{v}:
    get:
      tags: [Templates]
      summary: Fetch one version with its content
      security: [{ sessionCookie: [] }]
      parameters:
        - {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          }
        - { name: v, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: Version detail.
          content:
            {
              application/json:
                {
                  schema:
                    {
                      type: object,
                      properties:
                        {
                          version: { type: integer },
                          content: { type: object, additionalProperties: true },
                          published_at:
                            { type: [string, "null"], format: date-time },
                          created_at:
                            { $ref: "#/components/schemas/Timestamp" },
                        },
                    },
                },
            }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /templates/{id}/publish:
    post:
      tags: [Templates]
      summary: Publish the latest version (activates the template)
      description: "Validates content (rejects script tags, inline handlers, javascript URLs), requires a sample_value for every required variable, and renders before publishing. No request body."
      security: [{ sessionCookie: [] }]
      parameters:
        [
          {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          },
        ]
      responses:
        "200":
          {
            description: Published.,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            id: { $ref: "#/components/schemas/Id" },
                            state: { type: string, const: active },
                            current_version: { type: integer },
                          },
                      },
                  },
              },
          }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /templates/{id}/archive:
    post:
      tags: [Templates]
      summary: Archive a template
      security: [{ sessionCookie: [] }]
      parameters:
        [
          {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          },
        ]
      responses:
        "200":
          {
            description: Archived,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            id: { $ref: "#/components/schemas/Id" },
                            state: { type: string, const: archived },
                          },
                      },
                  },
              },
          }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /templates/{id}/preview:
    post:
      tags: [Templates]
      summary: Render the current version with sample or supplied data
      description: Renders the current version, starting from each variable's sample_value and overlaying the request body's `data`.
      security: [{ sessionCookie: [] }]
      parameters:
        [
          {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          },
        ]
      requestBody:
        content:
          {
            application/json:
              {
                schema:
                  {
                    type: object,
                    properties:
                      { data: { type: object, additionalProperties: true } },
                  },
              },
          }
      responses:
        "200":
          {
            description: Rendered content.,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            template_id: { $ref: "#/components/schemas/Id" },
                            channel: { $ref: "#/components/schemas/Channel" },
                            rendered:
                              { type: object, additionalProperties: true },
                          },
                      },
                  },
              },
          }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /webhooks/{id}:
    parameters:
      [
        {
          name: id,
          in: path,
          required: true,
          schema: { $ref: "#/components/schemas/Id" },
        },
      ]
    get:
      tags: [Webhooks]
      summary: Webhook endpoint detail
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          description: Endpoint detail (no signing secret).
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { $ref: "#/components/schemas/Id" }
                  workspace_id: { $ref: "#/components/schemas/Id" }
                  project_id: { type: [string, "null"] }
                  url: { type: string, format: uri }
                  description: { type: [string, "null"] }
                  events:
                    {
                      type: array,
                      items: { $ref: "#/components/schemas/WebhookEventType" },
                    }
                  status: { $ref: "#/components/schemas/WebhookStatus" }
                  consecutive_failures: { type: integer }
                  last_success_at: { type: [string, "null"], format: date-time }
                  last_response_code: { type: [integer, "null"] }
                  created_at: { $ref: "#/components/schemas/Timestamp" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Webhooks]
      summary: Update a webhook endpoint
      description: Only supplied fields change. Empty-string values are ignored.
      security: [{ sessionCookie: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, format: uri }
                description: { type: string }
                status: { $ref: "#/components/schemas/WebhookStatus" }
                events:
                  {
                    type: array,
                    items: { $ref: "#/components/schemas/WebhookEventType" },
                  }
      responses:
        "200":
          {
            description: Updated,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            id: { $ref: "#/components/schemas/Id" },
                            status: { type: string, const: updated },
                          },
                      },
                  },
              },
          }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Webhooks]
      summary: Delete a webhook endpoint
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          {
            description: Deleted,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          { status: { type: string, const: deleted } },
                      },
                  },
              },
          }
        "404": { $ref: "#/components/responses/NotFound" }
  /webhooks/{id}/deliveries:
    get:
      tags: [Webhooks]
      summary: Recent delivery attempts for a webhook endpoint
      security: [{ sessionCookie: [] }]
      parameters:
        [
          {
            name: id,
            in: path,
            required: true,
            schema: { $ref: "#/components/schemas/Id" },
          },
        ]
      responses:
        "200":
          description: Up to 100 delivery attempts, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { $ref: "#/components/schemas/Id" }
                        event_id: { $ref: "#/components/schemas/Id" }
                        attempt_number: { type: integer }
                        response_code: { type: [integer, "null"] }
                        status:
                          {
                            type: string,
                            enum: [pending, delivered, failed, given_up],
                          }
                        latency_ms: { type: [integer, "null"] }
                        scheduled_for:
                          { $ref: "#/components/schemas/Timestamp" }
                        attempted_at:
                          { type: [string, "null"], format: date-time }
                        next_retry_at:
                          { type: [string, "null"], format: date-time }
                        given_up_at:
                          { type: [string, "null"], format: date-time }
        "404": { $ref: "#/components/responses/NotFound" }
  /account/export:
    post:
      tags: [Account transfer]
      summary: Export a workspace as a passphrase-sealed bundle
      description: >-
        Workspace owners only, and not while impersonated. The bundle is the whole workspace as it was at one
        instant (a single REPEATABLE READ snapshot, so a change made while the export runs is either entirely in
        it or entirely out): every project (deleted ones too) with its default senders, sender_domains and
        provider pins; every provider with its credentials; every template, archived ones and campaign content
        included, with every version; every API key (revoked and expired too) as stored, so a secret that works
        keeps working; webhook endpoints with their signing secrets; DNS connections with their tokens; brands,
        contacts, topics, consent records and events, segments, signup forms, campaigns and the rest of the
        workspace's tables. Secrets are decrypted and re-sealed under the passphrase. Only the send history
        (sends, delivery events, webhook deliveries, campaign runs, analytics, audit log) is left out. Nothing is
        skipped for being deleted, archived or revoked. Audited as `workspace.exported`. Rate limited to 20
        transfer calls per 15 minutes per user.
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, passphrase]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                passphrase: { type: string, minLength: 12, maxLength: 200 }
      responses:
        "200":
          description: The sealed bundle (AES-256-GCM under a scrypt-derived key), served as an attachment.
          content:
            application/json:
              schema:
                type: object
                required: [format, version, kdf, cipher, ciphertext]
                properties:
                  format: { type: string, enum: [messagepipe.account-bundle] }
                  version: { type: integer, enum: [2], description: "Version 1 files (projects, providers, templates and key definitions only) are still accepted by the import." }
                  kdf: { type: object }
                  cipher: { type: object }
                  ciphertext: { type: string, description: base64 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
  /account/import/plan:
    post:
      tags: [Account transfer]
      summary: Dry run of an import; writes nothing
      description: >-
        Opens and validates the bundle and reports counts, the sender-domain changes the domain map makes,
        warnings and blocking errors. Returns the `confirm_hash` that the apply call must send back.
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [bundle, passphrase]
              properties:
                bundle: { type: object, description: The sealed bundle from /account/export }
                passphrase: { type: string, minLength: 12, maxLength: 200 }
                domain_map:
                  type: object
                  description: 'Old sender domain to new, e.g. {"dev.messagepipe.dev": "messagepipe.dev"}. At most 20 entries.'
                  additionalProperties: { type: string }
      responses:
        "200":
          description: What the import would do.
          content:
            application/json:
              schema:
                type: object
                properties:
                  source: { type: object, properties: { workspace_name: { type: string }, environment: { type: string } } }
                  exported_at: { type: string, format: date-time }
                  counts:
                    type: object
                    description: "projects, providers, templates, template_versions, api_keys as integers, and `other`: rows per other table that will be written."
                  not_restored:
                    type: array
                    description: Tables the bundle carries that are not written to the new workspace, and why (suppressions, consent hash chain, billing, members, ...).
                    items:
                      type: object
                      properties:
                        table: { type: string }
                        rows: { type: integer }
                        why: { type: string }
                  domain_changes:
                    type: array
                    items:
                      type: object
                      properties:
                        project: { type: string }
                        field: { type: string, enum: [default_from_email, default_reply_to, sender_domains] }
                        from: { type: string }
                        to: { type: string }
                  warnings: { type: array, items: { type: string } }
                  errors: { type: array, items: { type: string } }
                  confirm_hash: { type: string, minLength: 64, maxLength: 64 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
  /account/import:
    post:
      tags: [Account transfer]
      summary: Import a bundle as a new workspace owned by the caller
      description: >-
        Requires the `confirm_hash` from the dry run for the same bundle and domain map. Creates everything in
        one transaction with new IDs, on the Free plan; provider, webhook and DNS credentials are re-encrypted
        under this instance's key. An API key keeps the secret it has unless another key on this instance
        already answers to the same prefix (an import back into the instance it came from), and then a new key
        is minted; each new secret is returned once, here only. Nothing that would act by itself is revived:
        scheduled or running campaigns come back as drafts and tracking domains as pending. A bundle never
        grants anyone access: members are not restored. One import per reviewed bundle per user. Audited as
        `workspace.imported`.
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [bundle, passphrase, confirm_hash]
              properties:
                bundle: { type: object }
                passphrase: { type: string, minLength: 12, maxLength: 200 }
                domain_map: { type: object, additionalProperties: { type: string } }
                confirm_hash: { type: string, minLength: 64, maxLength: 64 }
                workspace_name: { type: string, minLength: 2, maxLength: 100 }
      responses:
        "201":
          description: The new workspace, its projects and the new API key secrets (shown once).
          content:
            application/json:
              schema:
                type: object
                properties:
                  workspace_id: { $ref: "#/components/schemas/Id" }
                  name: { type: string }
                  counts: { type: object }
                  api_keys_kept: { type: integer, description: Keys that kept their own secret. }
                  restored:
                    type: object
                    properties:
                      restored: { type: object, additionalProperties: { type: integer } }
                      skipped: { type: object, additionalProperties: { type: integer }, description: Rows not written because another workspace on the instance holds the same public slug, domain or key. }
                  projects:
                    type: array
                    items: { type: object, properties: { key: { type: string }, id: { type: string }, name: { type: string } } }
                  api_keys:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        project: { type: string }
                        name: { type: string }
                        key_prefix: { type: string }
                        secret: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
  /billing/plans:
    get:
      tags: [Billing]
      summary: The available plans and their per-channel quotas
      description: |
        `price_cents` is the price set in the operator console: what a NEW subscription to the plan is charged
        (a Stripe price for exactly that amount is created on demand). A workspace that subscribed earlier keeps the
        Stripe price it signed up on until it changes plan or cancels (grandfathering), so its invoices can differ
        from this list; `GET /billing/subscription` shows the plan's current console price, not the price an existing
        subscriber is billed (known gap).
      security: [{ sessionCookie: [] }]
      parameters:
        - name: workspace_id
          in: query
          required: false
          description: >-
            When given (the caller must be a member), the hidden `special` plan is included if this
            workspace may take it or is already on it. Without it, hidden plans are never listed.
          schema: { $ref: "#/components/schemas/Id" }
      responses:
        "200":
          description: Plan catalogue, in upgrade order.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { $ref: "#/components/schemas/Plan" }
                        name: { type: string }
                        price_cents: { type: integer }
                        quotas:
                          {
                            type: object,
                            additionalProperties: { type: integer },
                          }
  /billing/credits/auto-recharge:
    patch:
      tags: [Billing]
      summary: Configure automatic top-up for one channel
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, channel, enabled]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                channel: { $ref: "#/components/schemas/Channel" }
                enabled: { type: boolean }
                threshold:
                  {
                    type: integer,
                    minimum: 0,
                    description: "Required when enabled — refill when the balance drops below this.",
                  }
                amount:
                  {
                    type: integer,
                    minimum: 1000,
                    description: "Required when enabled — credits to purchase per refill.",
                  }
      responses:
        "200":
          description: The updated auto-recharge configuration for the channel.
          content:
            {
              application/json:
                {
                  schema:
                    {
                      type: object,
                      properties:
                        {
                          channel: { $ref: "#/components/schemas/Channel" },
                          auto_recharge:
                            {
                              type: object,
                              properties:
                                {
                                  enabled: { type: boolean },
                                  threshold: { type: integer },
                                  amount: { type: integer },
                                },
                            },
                        },
                    },
                },
            }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /sends:
    get:
      tags: [Sends]
      summary: List sends in a workspace with optional filters
      security: [{ sessionCookie: [] }]
      parameters:
        - { $ref: "#/components/parameters/WorkspaceIdQuery" }
        - {
            name: channel,
            in: query,
            required: false,
            schema: { $ref: "#/components/schemas/Channel" },
          }
        - { name: status, in: query, required: false, schema: { type: string } }
        - {
            name: project_id,
            in: query,
            required: false,
            schema: { $ref: "#/components/schemas/Id" },
          }
        - {
            name: q,
            in: query,
            required: false,
            description: Case-insensitive substring match on the recipient.,
            schema: { type: string },
          }
        - {
            name: limit,
            in: query,
            required: false,
            schema: { type: integer, minimum: 1, maximum: 200, default: 100 },
          }
      responses:
        "200":
          description: Sends, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { $ref: "#/components/schemas/Id" }
                        project_id: { type: [string, "null"] }
                        channel: { $ref: "#/components/schemas/Channel" }
                        sub_type: { type: [string, "null"] }
                        recipient: { type: string }
                        status: { type: string }
                        template_id: { type: [string, "null"] }
                        provider_id: { type: [string, "null"] }
                        failure_code: { type: [string, "null"] }
                        failure_reason: { type: [string, "null"] }
                        queued_at: { $ref: "#/components/schemas/Timestamp" }
                        sent_at: { type: [string, "null"], format: date-time }
                        delivered_at:
                          { type: [string, "null"], format: date-time }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /brands:
    post:
      tags: [Brands]
      summary: "Create a brand (developer+)"
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, project_id, name, postal_address]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                project_id: { $ref: "#/components/schemas/Id" }
                name: { type: string }
                slug: { type: string }
                postal_address: { type: string }
                default_from_name: { type: [string, "null"] }
                default_from_email: { type: [string, "null"] }
                reply_to: { type: [string, "null"] }
                contact_url: { type: [string, "null"] }
                privacy_url: { type: [string, "null"] }
                default_locale: { type: string }
                logo_url: { type: [string, "null"] }
                primary_color: { type: [string, "null"] }
                routing:
                  description: "Provider routing override for this brand's MARKETING mail: provider types in failover order (1 to 5, each an active email provider in the workspace). null clears it. If none of the listed providers can take a send, the send fails (no_providers); it never falls back to the workspace routing."
                  oneOf:
                    - type: "null"
                    - type: object
                      required: [providers]
                      properties:
                        providers: { type: array, minItems: 1, maxItems: 5, items: { type: string }, example: ["ses", "postmark"] }
                send_capacity_per_min:
                  description: "The brand's sending capacity in messages per minute; a campaign's throttle defaults to it. null = platform default."
                  type: [integer, "null"]
                  minimum: 1
                  maximum: 1000000
                tracking_enabled:
                  description: "Open and click tracking for this brand's campaigns. false = no tracked links or open pixel, whatever a campaign says. Default true."
                  type: boolean
      responses:
        "201": { description: "Created", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
    get:
      tags: [Brands]
      summary: "List brands in a workspace"
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }, { name: project_id, in: query, schema: { type: string } }, { name: status, in: query, schema: { type: string, enum: [active, archived, all] } }]
      responses:
        "200": { description: "Brands", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /brands/{id}/tracking-domain:
    get:
      tags: [Brands]
      summary: "The brand's tracking domain: current claim, DNS records to add, retired names"
      description: "Campaign links and the open pixel are served from this host once it is active, instead of the shared MessagePipe host. `available` is false on deployments without custom tracking domains."
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/Id" }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "{available, current: {id, domain, status, dns_verified_at, tls_verified_at, last_check_at, last_check_error, dns: {cname, txt}} | null, retired: [{domain, retired_at}]}", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Brands]
      summary: "Claim a tracking domain for the brand (developer+)"
      description: "Needs a subdomain you control (links.example.com). Returns the CNAME and TXT records to add. Replaces an unproven claim; one in use is retired and keeps serving the links already sent. A name this workspace retired earlier is taken back."
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/Id" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, domain]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                domain: { type: string, example: links.example.com }
      responses:
        "201": { description: "Claimed, pending proof", content: { application/json: { schema: { type: object } } } }
        "400": { description: "Not a usable name (apex, IP address, reserved host)" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "Already in use (by another account, or another brand in this workspace), or the feature is not available" }
    delete:
      tags: [Brands]
      summary: "Stop using the brand's tracking domain (developer+)"
      description: "Retires the active domain (it keeps serving links already sent) and drops an unproven claim. New campaigns use the shared host."
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/Id" }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "204": { description: "Done" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /brands/{id}/tracking-domain/check:
    post:
      tags: [Brands]
      summary: "Check the DNS records and HTTPS for the brand's tracking domain (developer+)"
      description: "Verifies the CNAME to the edge and the TXT token, then makes an HTTPS request that must reach the edge with a valid certificate. When all pass the domain becomes active. Safe to repeat; a domain that keeps failing becomes `degraded`: campaigns fall back to the shared host, links already sent keep working, and it recovers by itself when the checks pass."
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/Id" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
      responses:
        "200": { description: "The claim with its status and, if not active, what is missing", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { description: "The brand has no tracking domain" }
        "409": { description: "Another workspace has already proven control of this domain" }
  /brands/{id}:
    get:
      tags: [Brands]
      summary: "Brand detail"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "The brand", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Brands]
      summary: "Update a brand (developer+)"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                name: { type: string }
                slug: { type: string }
                postal_address: { type: string }
                default_from_name: { type: [string, "null"] }
                default_from_email: { type: [string, "null"] }
                reply_to: { type: [string, "null"] }
                contact_url: { type: [string, "null"] }
                privacy_url: { type: [string, "null"] }
                default_locale: { type: string }
                logo_url: { type: [string, "null"] }
                primary_color: { type: [string, "null"] }
                routing:
                  description: "Provider routing override for this brand's MARKETING mail: provider types in failover order (1 to 5, each an active email provider in the workspace). null clears it. If none of the listed providers can take a send, the send fails (no_providers); it never falls back to the workspace routing."
                  oneOf:
                    - type: "null"
                    - type: object
                      required: [providers]
                      properties:
                        providers: { type: array, minItems: 1, maxItems: 5, items: { type: string }, example: ["ses", "postmark"] }
                send_capacity_per_min:
                  description: "The brand's sending capacity in messages per minute; a campaign's throttle defaults to it. null = platform default."
                  type: [integer, "null"]
                  minimum: 1
                  maximum: 1000000
                tracking_enabled:
                  description: "Open and click tracking for this brand's campaigns. false = no tracked links or open pixel, whatever a campaign says. Default true."
                  type: boolean
      responses:
        "200": { description: "Updated", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
    delete:
      tags: [Brands]
      summary: "Archive a brand (developer+); history is kept"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "204": { description: "Archived" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /brands/{id}/logo:
    get:
      tags: [Brands]
      summary: "The brand's BIMI sender picture, certificate address, and per-domain DNS records (any member)"
      description: >
        BIMI lets a domain publish a logo that mail apps MAY show next to its messages, through a TXT record at
        `default._bimi.<domain>`: `v=BIMI1; l=<https URL of the SVG>; a=<https URL of a VMC/CMC certificate>;`. This lists the brand's
        registered sender domains with that exact record and what is and is not ready. The sender picture is NOT used in email bodies,
        previews, the preference page or signup forms (those keep `logo_url`).
        The answer (`PictureView`) is the same for every operation here:
        `{ logo, certificate_url, project_logo?, domains: [{ domain, source, record, readiness }] }`.
        `logo` is the scope's own current picture `{id, url, title, byte_size, created_at}` (`url` is the https address that
        goes in the record's `l=` tag) or null; `project_logo` is the project's default, on a brand; `certificate_url` is the `a=` value.
        Each domain carries `source` (`brand`, `project` or null), the TXT `record` to publish (`{name, type, value}`, or null when there
        is nothing to publish) and `readiness`: a list of `{key: picture|dmarc|certificate|record, state: ok|todo|info|unknown, text}`.
        Nothing is published for the customer, and a published record does not guarantee that any mail app shows the picture.
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "PictureView", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Brands]
      summary: "Upload the brand's sender picture (developer+)"
      description: >
        The request body is the SVG file itself (Content-Type image/svg+xml). Only SVG Tiny-PS is accepted: root `<svg>` with
        `version="1.2"` and `baseProfile="tiny-ps"`, a square `viewBox`, no `x`/`y` on the root, a `<title>`, at most 32 KB. The file is checked
        against an allowlist and refused if it has a DOCTYPE or entities, processing instructions, CDATA, markup in comments, scripts, event
        handlers, `<style>`, `<image>`, `<foreignObject>`, `<use>`, `<a>`, `<text>`, animation, references to other files, `data:` URIs, or any element or
        attribute outside the list. The file is stored byte for byte as uploaded (a VMC certificate is issued for one exact file). It is served from
        the pages host at a new unguessable `https://.../l/<key>.svg`. Uploading the same file again changes nothing. A replaced picture keeps
        working at its old URL so a published record does not break at once, but only the newest 3 previous pictures are kept; older ones are deleted.
        60 changes per workspace per UTC day: only a change counts (a rejected file, an identical re-upload and a refused request do not), and the 61st is `429`.
        Returns the new picture `{id, url, title, byte_size, created_at}`.
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      requestBody:
        required: true
        content:
          image/svg+xml: { schema: { type: string, format: binary } }
      responses:
        "200": { description: "The stored picture", content: { application/json: { schema: { type: object } } } }
        "400": { description: "Not an acceptable SVG. error.details.constraint is `svg:<reason>` (empty, too_large, encoding, not_svg, xml_declaration, doctype, cdata, processing_instruction, comment, entity, malformed, element, attribute, attribute_value, namespace, text, depth, too_many_elements, profile, viewbox, root_position, title, href, duplicate_id, style) and error.details.line is the line of the problem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "The brand is archived, or another upload for it was in progress" }
        "429": { description: "Too many uploads for this workspace today" }
    patch:
      tags: [Brands]
      summary: "Set or clear the brand's certificate address (developer+)"
      description: >
        `certificate_url` is where the brand's VMC or CMC certificate (a .pem) is published; it goes in the record's `a=` tag. It must be a plain
        https URL (no spaces, quotation marks or semicolons); null or an empty string clears it. MessagePipe never fetches it. Returns the
        PictureView described under GET.
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [certificate_url]
              properties:
                certificate_url: { type: [string, "null"], example: "https://example.com/vmc.pem" }
      responses:
        "200": { description: "PictureView", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "The brand is archived" }
    delete:
      tags: [Brands]
      summary: "Remove the brand's sender picture (developer+)"
      description: >
        A takedown. Hard-deletes EVERY stored version of the brand's picture (the current one and the replaced ones) in one transaction, so all of
        their addresses answer 404 immediately; the audit event records `hard_delete: true`. The brand's domains then use the project's default
        picture, if it has one. Copies that mail apps or a CDN already cached can outlive the deletion for a while, and the DNS record still points
        at the old address until the customer removes it. Removing when there is no picture is not an error (204).
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "204": { description: "Removed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /brands/domains:
    get:
      tags: [Brands]
      summary: "Every active brand's sender domains in one answer (any member)"
      description: "brand_id, domain, purpose and state only, for forms that offer a brand's verified domains. The DNS records are on the brand's own domain list."
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "{data[]}", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /brands/{id}/domains:
    post:
      tags: [Brands]
      summary: "Register a sender domain (developer+)"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, domain]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                domain: { type: string }
                purpose: { type: string, enum: [marketing, transactional, both] }
                provider_types: { type: array, items: { type: string } }
                dkim_selectors: { type: array, items: { type: string } }
      responses:
        "201": { description: "Created, with the DNS records to publish", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
    get:
      tags: [Brands]
      summary: "List a brand's sender domains with state and DNS records"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "Domains", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /brands/{id}/domains/{d}:
    get:
      tags: [Brands]
      summary: "One sender domain: state, last check result, DNS records"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { name: d, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "The domain", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /brands/{id}/domains/{d}/check:
    post:
      tags: [Brands]
      summary: "Queue a DNS check (developer+); returns the pending job if one is already queued"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { name: d, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
      responses:
        "202": { description: "Queued: job_id, domain_id, status", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /topics:
    post:
      tags: [Topics]
      summary: "Create a topic (marketer+)"
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, brand_id, name]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                brand_id: { $ref: "#/components/schemas/Id" }
                name: { type: string }
                description: { type: [string, "null"] }
                default_state: { type: string, enum: [opt_in, opt_out] }
                channels: { type: array, items: { $ref: "#/components/schemas/Channel" } }
                double_opt_in: { type: string, enum: [off, optional, required] }
                min_evidence_tier: { type: string, enum: [A, B, C] }
                visibility: { type: string, enum: [public, private] }
      responses:
        "201": { description: "Created", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
    get:
      tags: [Topics]
      summary: "List topics"
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }, { name: brand_id, in: query, schema: { type: string } }, { name: status, in: query, schema: { type: string, enum: [active, archived, all] } }]
      responses:
        "200": { description: "Topics", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /topics/{id}:
    get:
      tags: [Topics]
      summary: "Topic detail"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "The topic", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Topics]
      summary: "Update a topic (marketer+); changes to who may be mailed are audited at warning severity"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                name: { type: string }
                description: { type: [string, "null"] }
                default_state: { type: string, enum: [opt_in, opt_out] }
                channels: { type: array, items: { $ref: "#/components/schemas/Channel" } }
                double_opt_in: { type: string, enum: [off, optional, required] }
                min_evidence_tier: { type: string, enum: [A, B, C] }
                visibility: { type: string, enum: [public, private] }
                status: { type: string, enum: [active, archived] }
      responses:
        "200": { description: "Updated", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
    delete:
      tags: [Topics]
      summary: "Archive a topic (marketer+)"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "204": { description: "Archived" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /consent-statements:
    post:
      tags: [Consent statements]
      summary: "Create a statement or its next version (marketer+); runs the CASL lint (warnings never block)"
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, brand_id, text, produces_basis, grants]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                brand_id: { $ref: "#/components/schemas/Id" }
                statement_id: { type: string, description: "Add a version or locale to an existing statement" }
                version: { type: integer, minimum: 1 }
                locale: { type: string }
                text: { type: string }
                produces_basis: { type: string, enum: [express, implied_purchase, implied_contract, implied_inquiry, implied_publication, implied_disclosed, pecr_soft_opt_in] }
                grants: { type: array, items: { type: object, required: [topic_id, channels], properties: { topic_id: { type: string }, channels: { type: array, items: { type: string } } } } }
      responses:
        "201": { description: "Created, with lint findings", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
    get:
      tags: [Consent statements]
      summary: "List statements"
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }, { name: brand_id, in: query, schema: { type: string } }]
      responses:
        "200": { description: "Statements, newest first", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /consent-statements/{id}:
    get:
      tags: [Consent statements]
      summary: "Every version and locale of one statement"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "Versions, newest first", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /contacts:
    get:
      tags: [Contacts]
      summary: "Search contacts by exact email, phone or external id (viewers get masked personal data)"
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }, { name: email, in: query, schema: { type: string } }, { name: phone, in: query, schema: { type: string } }, { name: external_id, in: query, schema: { type: string } }, { name: status, in: query, schema: { type: string, enum: [active, erased, all] } }, { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200 } }, { name: cursor, in: query, schema: { type: string } }]
      responses:
        "200": { description: "A page: data[] and next_cursor", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /contacts/{id}:
    get:
      tags: [Contacts]
      summary: "Contact with its consent records (viewers get masked personal data)"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "The contact", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /contacts/{id}/consent/evidence:
    get:
      tags: [Contacts]
      summary: "Full consent evidence: events, canonical form, hash-chain position, statement shown (marketer+)"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "Evidence", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /contacts/{id}/consent/evidence.pdf:
    get:
      tags: [Contacts]
      summary: "Consent evidence as a PDF report: every event with tier, IP, browser, page and the wording shown, plus each sealed event's canonical text and hashes (marketer+; audited; not digitally signed)"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "The PDF report, as an attachment named consent-evidence-{id}.pdf", content: { application/pdf: { schema: { type: string, format: binary } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /contacts/{id}/export:
    get:
      tags: [Contacts]
      summary: "Subject-access export as a JSON download (owner/admin); audited"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "Contact, consent and evidence", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /contacts/tags:
    get:
      tags: [Contacts]
      summary: "Tags in use with how many contacts carry each (any member)"
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "data[] of {tag, contacts}", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /contacts/{id}/tags:
    post:
      tags: [Contacts]
      summary: "Add tags to a contact (marketer+)"
      description: "Tags are stored trimmed and lower-case. At most 20 per call and 50 per contact; a tag cannot contain a comma, semicolon or vertical bar (the CSV import splits on them)."
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, tags]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                tags: { type: array, items: { type: string, maxLength: 64 }, minItems: 1, maxItems: 20 }
      responses:
        "200": { description: "{tags, added}", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "409": { description: "The contact was erased" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Contacts]
      summary: "Remove tags from a contact (marketer+)"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, tags]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                tags: { type: array, items: { type: string } }
      responses:
        "200": { description: "{tags, removed}", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /contacts/{id}/timeline:
    get:
      tags: [Contacts]
      summary: "Consent events for a contact, newest first, without network evidence (marketer+)"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }, { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200 } }]
      responses:
        "200": { description: "data[]", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /contacts/{id}/sends:
    get:
      tags: [Contacts]
      summary: "Recent sends to a contact (by id or address) with delivery events; no message content (marketer+)"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }, { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100 } }]
      responses:
        "200": { description: "data[] of sends with events[]", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /contacts/{id}/suppression:
    get:
      tags: [Contacts]
      summary: "Whether the contact's address is suppressed in this workspace (marketer+); the address key is never returned"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "{suppressed, entries[{id, reason, brand_id, created_at, expires_at}]}", content: { application/json: { schema: { type: object } } } }
        "503": { description: "The Send API is not configured or not reachable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /contacts/{id}/erase:
    post:
      tags: [Contacts]
      summary: "Erase a contact (owner/admin); irreversible. Refused in impersonation sessions."
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, confirm]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                confirm: { type: string, description: "Must equal the contact id" }
      responses:
        "200": { description: "Erased: events pseudonymised, suppressions added", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /segments:
    post:
      tags: [Segments]
      summary: "Create a segment (marketer+); dynamic needs rules, static has members"
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, name, type]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                name: { type: string, maxLength: 120, description: "Unique among active segments, case-insensitively" }
                description: { type: [string, "null"] }
                type: { type: string, enum: [dynamic, static] }
                rules: { type: object, description: "Rule tree: an object with exactly one key. all/any (arrays of rules), not (one rule), attribute {name, op, value}, tag {op: has|has_any|has_all, value}, topic {id, state: subscribed|unsubscribed|pending|none, channel?}, consent {basis, topic_id?, min_tier?}, created_at {op, value}, field {name: country|locale|email_domain, op, value}. Limits: depth 6, 100 nodes. Engagement predicates arrive with Campaigns C2 and are rejected as engagement_unavailable until then. Invalid rules return 400 with details.problems[] ({path, code, message})." }
      responses:
        "201": { description: "Created", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
    get:
      tags: [Segments]
      summary: "List segments with their cached counts"
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }, { name: status, in: query, schema: { type: string, enum: [active, archived, all] } }, { name: type, in: query, schema: { type: string, enum: [dynamic, static, all] } }]
      responses:
        "200": { description: "Segments", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /segments/schema:
    get:
      tags: [Segments]
      summary: "What the rule builder can offer: attribute definitions and the 200 most-used tags"
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "{attributes[{key,type,enum_values}], tags[{tag,contacts}]}", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /segments/validate:
    post:
      tags: [Segments]
      summary: "Check rules without saving (marketer+); 200 either way, problems[] says what is wrong"
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, rules]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                rules: { type: object, description: "Rule tree: an object with exactly one key. all/any (arrays of rules), not (one rule), attribute {name, op, value}, tag {op: has|has_any|has_all, value}, topic {id, state: subscribed|unsubscribed|pending|none, channel?}, consent {basis, topic_id?, min_tier?}, created_at {op, value}, field {name: country|locale|email_domain, op, value}. Limits: depth 6, 100 nodes. Engagement predicates arrive with Campaigns C2 and are rejected as engagement_unavailable until then. Invalid rules return 400 with details.problems[] ({path, code, message})." }
      responses:
        "200": { description: "{valid, problems[]}", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /segments/preview:
    post:
      tags: [Segments]
      summary: "Count (and, for marketer+, a sample of 20) for unsaved rules, a saved segment, or everyone; optionally only a topic's subscribers"
      description: "The count is an upper bound: suppressions and per-send eligibility are applied again at send time. A topic filter is applied first, so a segment can only narrow a topic's audience."
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                rules: { type: object }
                segment_id: { $ref: "#/components/schemas/Id" }
                topic_id: { $ref: "#/components/schemas/Id" }
                channel: { $ref: "#/components/schemas/Channel" }
                sample: { type: boolean, description: "Ignored for viewers" }
      responses:
        "200": { description: "{count, upper_bound, sample?}", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /segments/{id}:
    get:
      tags: [Segments]
      summary: "Segment detail"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "The segment", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Segments]
      summary: "Rename, change rules (dynamic), archive or restore (marketer+); changing rules clears the cached count"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                name: { type: string }
                description: { type: [string, "null"] }
                status: { type: string, enum: [active, archived] }
                rules: { type: object }
      responses:
        "200": { description: "The segment", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
    delete:
      tags: [Segments]
      summary: "Archive a segment (marketer+); never deleted, a sent campaign keeps pointing at it"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "204": { description: "Archived (idempotent)" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /segments/{id}/count:
    get:
      tags: [Segments]
      summary: "Live member count, cached on the segment for up to 60 seconds (never cached with topic_id)"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }, { name: topic_id, in: query, schema: { type: string } }, { name: channel, in: query, schema: { type: string } }, { name: refresh, in: query, schema: { type: boolean } }]
      responses:
        "200": { description: "{count, counted_at, cached, upper_bound}", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /segments/{id}/sample:
    get:
      tags: [Segments]
      summary: "Twenty matching contacts (marketer+; personal data)"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }, { name: topic_id, in: query, schema: { type: string } }, { name: channel, in: query, schema: { type: string } }]
      responses:
        "200": { description: "{count, data[], upper_bound}", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /segments/{id}/members:
    get:
      tags: [Segments]
      summary: "Members of a static segment (viewers get masked addresses)"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }, { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200 } }, { name: cursor, in: query, schema: { type: string } }]
      responses:
        "200": { description: "A page: data[] and next_cursor", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Segments]
      summary: "Add contacts to a static segment by id, address and/or CSV text (marketer+, at most 5000 per call)"
      description: "Erased and unknown contacts are not added and are counted in not_found; unparseable addresses are listed in invalid_sample."
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                contact_ids: { type: array, items: { type: string } }
                emails: { type: array, items: { type: string } }
                csv: { type: string, description: "First column of each line; an 'email' header line is skipped" }
      responses:
        "200": { description: "{added, already_members, not_found, not_found_sample, invalid_emails, invalid_sample}", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Segments]
      summary: "Remove contacts from a static segment (marketer+)"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                contact_ids: { type: array, items: { type: string } }
                emails: { type: array, items: { type: string } }
                csv: { type: string }
      responses:
        "200": { description: "{removed}", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /imports:
    post:
      tags: [Imports]
      summary: "Upload a CSV to import (marketer+); the body is the file"
      description: "Content-Type text/csv, UTF-8 with a header row, at most 10 MB, 100,000 rows and 200 columns. Nothing is added to the audience by an upload. The file is deleted when the import ends, is cancelled, or goes unused for 24 hours."
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }, { name: brand_id, in: query, required: true, schema: { $ref: "#/components/schemas/Id" } }, { name: filename, in: query, schema: { type: string, maxLength: 255 } }]
      requestBody:
        required: true
        content:
          text/csv:
            schema: { type: string }
      responses:
        "201": { description: "The import, status draft, with headers and the first rows", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    get:
      tags: [Imports]
      summary: "Recent imports (marketer+)"
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }, { name: brand_id, in: query, schema: { $ref: "#/components/schemas/Id" } }]
      responses:
        "200": { description: "imports[]", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /imports/declaration:
    get:
      tags: [Imports]
      summary: "The exact declaration a person agrees to when confirming an import"
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "{text}", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /imports/{id}:
    get:
      tags: [Imports]
      summary: "One import: status, configuration, progress and report (marketer+)"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "The import", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /imports/{id}/config:
    put:
      tags: [Imports]
      summary: "Set the column mapping and consent options (marketer+); saved only if valid"
      description: "Always 200. valid=false lists every problem at once with the field it belongs to. Saving discards any earlier dry run. Allowed while the import is a draft or a finished dry run. Bases that can be imported: express, implied_purchase, implied_contract, implied_inquiry (not pecr_soft_opt_in, implied_publication or implied_disclosed). Topics that require double opt-in are refused."
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, mapping, options]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                mapping:
                  type: object
                  required: [email]
                  description: "Field to column header. Also first_name, last_name, locale, country, timezone, external_id, tags, consent_date, basis_date, and attributes {key: header}."
                options:
                  type: object
                  required: [existing, topic_ids, basis, statement_id, statement_version, collection_method, collection_from]
                  properties:
                    existing: { type: string, enum: [update, skip] }
                    topic_ids: { type: array, items: { type: string }, minItems: 1, maxItems: 10 }
                    basis: { type: string }
                    statement_id: { type: string }
                    statement_version: { type: integer }
                    collection_method: { type: string, maxLength: 1000 }
                    original_platform: { type: string, maxLength: 200 }
                    collection_from: { type: string, format: date }
                    collection_to: { type: string, format: date }
      responses:
        "200": { description: "{valid, problems[{field, message}], import?}", content: { application/json: { schema: { type: object } } } }
        "409": { description: "The import can no longer be configured" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /imports/{id}/dry-run:
    post:
      tags: [Imports]
      summary: "Queue a dry run (marketer+): the whole file is processed and rolled back; the report says what an import would do"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [workspace_id], properties: { workspace_id: { $ref: "#/components/schemas/Id" } } }
      responses:
        "202": { description: "Queued", content: { application/json: { schema: { type: object } } } }
        "409": { description: "Not configured, wrong state, or the file is gone" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /imports/{id}/commit:
    post:
      tags: [Imports]
      summary: "Attest and start the import (marketer+)"
      description: "Refused unless a dry run finished for the saved configuration. Records the declaration text, the file's SHA-256 and the collection details as an append-only attestation, which every imported subscription cites. Audited at warning severity. People who unsubscribed or are suppressed are never subscribed."
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, declaration_accepted]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                declaration_accepted: { type: boolean }
      responses:
        "202": { description: "Queued", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "409": { description: "No current dry run, or already started" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /imports/{id}/cancel:
    post:
      tags: [Imports]
      summary: "Cancel an import that has not ended (marketer+); rows already applied stay"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [workspace_id], properties: { workspace_id: { $ref: "#/components/schemas/Id" } } }
      responses:
        "200": { description: "Cancelled", content: { application/json: { schema: { type: object } } } }
        "409": { description: "Already ended" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /imports/{id}/issues:
    get:
      tags: [Imports]
      summary: "Rows that did not simply import, by line (marketer+); format=csv downloads up to 20,000 of them"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }, { name: run, in: query, schema: { type: string, enum: [dry_run, import] } }, { name: after, in: query, schema: { type: integer } }, { name: format, in: query, schema: { type: string, enum: [json, csv] } }]
      responses:
        "200": { description: "issues[] with total and next_after, or text/csv", content: { application/json: { schema: { type: object } }, text/csv: { schema: { type: string } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /content/variables:
    get:
      tags: [Content]
      summary: "Merge variables an author can use (any member)"
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "variables[]", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /content/layouts:
    get:
      tags: [Content]
      summary: "A brand's layouts, the generated default first (any member)"
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }, { name: brand_id, in: query, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      responses:
        "200": { description: "data[]", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Content]
      summary: "Create a layout (marketer+)"
      description: "The compliance footer is never part of a layout's HTML; it can be restyled within readable limits but not removed."
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, brand_id, name]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                brand_id: { $ref: "#/components/schemas/Id" }
                name: { type: string, maxLength: 100 }
                html: { type: [string, "null"], description: "Own layout HTML with one {{{body}}} slot; null = generated structure" }
                style: { type: object, description: "background, content_background, text, link, font (sans|serif|mono), width (480-720), footer{background,text,link,font_size 11-16,align}" }
                is_default: { type: boolean }
      responses:
        "200": { description: "The layout (201)", content: { application/json: { schema: { type: object } } } }
        "409": { description: "A layout with that name exists for the brand" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /content/layouts/validate:
    post:
      tags: [Content]
      summary: "Check a layout without saving (any member)"
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, brand_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                brand_id: { $ref: "#/components/schemas/Id" }
                name: { type: string, maxLength: 100 }
                html: { type: [string, "null"], description: "Own layout HTML with one {{{body}}} slot; null = generated structure" }
                style: { type: object, description: "background, content_background, text, link, font (sans|serif|mono), width (480-720), footer{background,text,link,font_size 11-16,align}" }
                is_default: { type: boolean }
      responses:
        "200": { description: "{valid, problems[]}", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /content/layouts/{id}:
    get:
      tags: [Content]
      summary: "One layout, with its effective HTML (any member)"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }, { name: brand_id, in: query, schema: { $ref: "#/components/schemas/Id" }, description: "Required for the generated layout (id `default`)" }]
      responses:
        "200": { description: "The layout", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Content]
      summary: "Change a layout (marketer+); the brand cannot change"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, name]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                brand_id: { $ref: "#/components/schemas/Id" }
                name: { type: string, maxLength: 100 }
                html: { type: [string, "null"], description: "Own layout HTML with one {{{body}}} slot; null = generated structure" }
                style: { type: object, description: "background, content_background, text, link, font (sans|serif|mono), width (480-720), footer{background,text,link,font_size 11-16,align}" }
                is_default: { type: boolean }
      responses:
        "200": { description: "The layout", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /content/layouts/{id}/archive:
    post:
      tags: [Content]
      summary: "Archive a layout (marketer+)"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
      responses:
        "200": { description: "{id, status}", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /content/preview:
    post:
      tags: [Content]
      summary: "Render content exactly as it would be sent (any member; marketer+ with contact_id)"
      description: "Nothing is stored. Links are inert. MJML is compiled first; an invalid document is a 400 with the compiler's problems."
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, brand_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                brand_id: { $ref: "#/components/schemas/Id" }
                topic_id: { $ref: "#/components/schemas/Id", description: "Topic the email is sent under; the footer names it" }
                layout_id: { type: string, description: "A layout id, or `default` for the generated one; omitted = the brand's default" }
                contact_id: { $ref: "#/components/schemas/Id", description: "Render for this contact (marketer+)" }
                campaign_name: { type: string }
                content: { $ref: "#/components/schemas/CampaignContent" }
                template_id: { $ref: "#/components/schemas/Id", description: "Use a saved campaign template instead of `content`" }
                version: { type: integer, description: "With template_id: a specific version; default the latest" }
      responses:
        "200": { description: "subject, preheader, html, text, size_bytes, undefined_variables[]", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /content/preflight:
    post:
      tags: [Content]
      summary: "Pre-flight checks on content (any member)"
      description: "Always 200: findings are the product. ok is false when any finding is an error."
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, brand_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                brand_id: { $ref: "#/components/schemas/Id" }
                topic_id: { $ref: "#/components/schemas/Id", description: "Topic the email is sent under; the footer names it" }
                layout_id: { type: string, description: "A layout id, or `default` for the generated one; omitted = the brand's default" }
                contact_id: { $ref: "#/components/schemas/Id", description: "Render for this contact (marketer+)" }
                campaign_name: { type: string }
                content: { $ref: "#/components/schemas/CampaignContent" }
                template_id: { $ref: "#/components/schemas/Id", description: "Use a saved campaign template instead of `content`" }
                version: { type: integer, description: "With template_id: a specific version; default the latest" }
      responses:
        "200": { description: "{ok, findings[{code, severity, message}], size_bytes}", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /content/test-send:
    post:
      tags: [Content]
      summary: "Send a test to up to 10 addresses (marketer+)"
      description: "Goes through the real sending path and costs one credit per address. Subject is prefixed [TEST] and a banner is added. Recorded with is_test and no campaign_id, so it is not counted in campaign reports."
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, brand_id, topic_id, recipients]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                brand_id: { $ref: "#/components/schemas/Id" }
                topic_id: { $ref: "#/components/schemas/Id", description: "Topic the email is sent under; the footer names it" }
                layout_id: { type: string, description: "A layout id, or `default` for the generated one; omitted = the brand's default" }
                contact_id: { $ref: "#/components/schemas/Id", description: "Render for this contact (marketer+)" }
                campaign_name: { type: string }
                content: { $ref: "#/components/schemas/CampaignContent" }
                template_id: { $ref: "#/components/schemas/Id", description: "Use a saved campaign template instead of `content`" }
                version: { type: integer, description: "With template_id: a specific version; default the latest" }
                recipients: { type: array, items: { type: string }, maxItems: 10 }
                from_email: { type: string }
                from_name: { type: string }
                reply_to: { type: string }
      responses:
        "200": { description: "results[{recipient, send_id, status: queued|suppressed|failed, reason}]", content: { application/json: { schema: { type: object } } } }
        "402": { description: "No credits left" }
        "503": { description: "Test sends are not enabled on this deployment" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /forms:
    get:
      tags: [Signup forms]
      summary: "List signup forms (any member)"
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }, { name: brand_id, in: query, schema: { $ref: "#/components/schemas/Id" } }, { name: status, in: query, schema: { type: string, enum: [draft, active, archived] } }]
      responses:
        "200": { description: "data[] of forms, each with hosted_url", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Signup forms]
      summary: "Create a signup form (marketer+)"
      description: "A form is the public face of one registered consent statement. The statement must be express, belong to the brand and grant every listed topic by email. The wording shown on the page and recorded as evidence is the statement's registered text, never text typed into the form. Hosted at /s/{slug} on the public pages host. A form that is active cannot list a topic that requires double opt-in (not supported yet). The slug is generated from the name unless given, and is unique across the platform."
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, brand_id, name, statement_id, topic_ids]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                brand_id: { $ref: "#/components/schemas/Id" }
                name: { type: string, maxLength: 100 }
                slug: { type: string, description: "3-63 characters of a-z, 0-9 and hyphen; immutable once the form leaves draft" }
                status: { type: string, enum: [draft, active], default: draft }
                statement_id: { $ref: "#/components/schemas/Id" }
                statement_version: { type: integer, minimum: 1, description: "Default the newest version in the locale" }
                statement_locale: { type: string, default: en }
                topic_ids: { type: array, items: { $ref: "#/components/schemas/Id" }, minItems: 1, maxItems: 10 }
                fields: { type: object, additionalProperties: { type: string }, description: "Extra inputs on the form: first_name and last_name, each `off`, `optional` or `required`" }
                title: { type: [string, "null"], maxLength: 200 }
                intro: { type: [string, "null"], maxLength: 2000 }
                button_label: { type: [string, "null"], maxLength: 60 }
                success_message: { type: [string, "null"], maxLength: 1000 }
                redirect_url: { type: [string, "null"], description: "https URL to send the person to after signing up" }
                allowed_origins: { type: array, items: { type: string }, maxItems: 20, description: "Sites allowed to embed the form or post to it from a browser (https origins)" }
      responses:
        "201": { description: "The form, with hosted_url, embed (iframe, html, script) and warnings[]", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "Name or slug already in use" }
  /forms/{id}:
    parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
    get:
      tags: [Signup forms]
      summary: "One form with embed code and warnings (any member)"
      description: "warnings[] may include double_opt_in_recommended and form_out_of_date (a topic was archived or the statement changed after the form was saved)."
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "The form", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Signup forms]
      summary: "Change a form (marketer+)"
      description: "Same fields as create except brand_id. Pointing at another statement or locale takes its newest version unless one is named. Forms are archived rather than deleted. Audited."
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                name: { type: string }
                slug: { type: string, description: "Only while the form is a draft" }
                status: { type: string, enum: [draft, active, archived] }
                statement_id: { $ref: "#/components/schemas/Id" }
                statement_version: { type: integer }
                statement_locale: { type: string }
                topic_ids: { type: array, items: { $ref: "#/components/schemas/Id" } }
                fields: { type: object, additionalProperties: { type: string } }
                title: { type: [string, "null"] }
                intro: { type: [string, "null"] }
                button_label: { type: [string, "null"] }
                success_message: { type: [string, "null"] }
                redirect_url: { type: [string, "null"] }
                allowed_origins: { type: array, items: { type: string } }
      responses:
        "200": { description: "The updated form", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "Name or slug already in use" }
  /campaign-templates:
    get:
      tags: [Content]
      summary: "List campaign templates (any member)"
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }, { name: project_id, in: query, schema: { $ref: "#/components/schemas/Id" } }, { name: state, in: query, schema: { type: string, enum: [draft, active, archived] } }, { name: brand_id, in: query, schema: { $ref: "#/components/schemas/Id" } }]
      responses:
        "200": { description: "data[]", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Content]
      summary: "Create a campaign template (marketer+)"
      description: "Stored as a template version with metadata.kind = campaign. MJML is compiled on save. These do not appear in /templates and the generic template routes refuse them."
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, project_id, name, content]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                project_id: { $ref: "#/components/schemas/Id" }
                name: { type: string, maxLength: 100 }
                brand_id: { $ref: "#/components/schemas/Id", description: "Optional; when set, publishing runs the pre-flight with this brand" }
                base_version: { type: integer, description: "PUT only: the version being edited; a stale value is a 409" }
                content: { $ref: "#/components/schemas/CampaignContent" }
      responses:
        "200": { description: "The template (201)", content: { application/json: { schema: { type: object } } } }
        "409": { description: "A template with that name exists in the project" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /campaign-templates/{id}:
    get:
      tags: [Content]
      summary: "One campaign template with its latest version (any member)"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "The template", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Content]
      summary: "Save content (marketer+)"
      description: "While the latest version is a draft it is edited in place; after publishing, the next save starts a new version. Published versions are never changed."
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, base_version, content]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                project_id: { $ref: "#/components/schemas/Id" }
                name: { type: string, maxLength: 100 }
                brand_id: { $ref: "#/components/schemas/Id", description: "Optional; when set, publishing runs the pre-flight with this brand" }
                base_version: { type: integer, description: "PUT only: the version being edited; a stale value is a 409" }
                content: { $ref: "#/components/schemas/CampaignContent" }
      responses:
        "200": { description: "The template", content: { application/json: { schema: { type: object } } } }
        "409": { description: "A newer version exists (details.latest_version), or the template is archived" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /campaign-templates/{id}/versions:
    get:
      tags: [Content]
      summary: "Version history (any member)"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "data[]", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /campaign-templates/{id}/versions/{v}:
    get:
      tags: [Content]
      summary: "One version (any member)"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { name: v, in: path, required: true, schema: { type: integer } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "version, content, published_at", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /campaign-templates/{id}/publish:
    post:
      tags: [Content]
      summary: "Publish the latest version (marketer+)"
      description: "With a brand bound, the pre-flight runs and any error blocks publishing (400, details.findings). Without one, the brand-dependent checks are skipped and the response says so."
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
      responses:
        "200": { description: "{id, state, current_version, preflight: ran|skipped_no_brand, findings[]}", content: { application/json: { schema: { type: object } } } }
        "409": { description: "Someone saved a newer version while the checks ran" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /campaign-templates/{id}/archive:
    post:
      tags: [Content]
      summary: "Archive a campaign template (marketer+)"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
      responses:
        "200": { description: "{id, state}", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /campaigns:
    get:
      tags: [Campaigns]
      summary: "List campaigns and newsletters, newest first, with the progress of their latest run"
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }, { name: status, in: query, schema: { type: string, enum: [all, draft, scheduled, running, paused, completed, cancelled, failed] } }]
      responses:
        "200": { description: "{data[]}", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Campaigns]
      summary: "Create a draft campaign (marketer+)"
      description: "The brand, topic, segment and template must exist in the workspace and fit together (the topic belongs to the brand)."
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, name, brand_id, topic_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                name: { type: string }
                kind: { type: string, enum: [campaign, newsletter] }
                brand_id: { type: string }
                topic_id: { type: string }
                segment_id: { type: [string, "null"] }
                template_id: { type: [string, "null"] }
                layout_id: { type: [string, "null"] }
                from_name: { type: [string, "null"] }
                from_email: { type: [string, "null"] }
                reply_to: { type: [string, "null"] }
                throttle_per_min: { type: [integer, "null"], minimum: 1, maximum: 1000000 }
      responses:
        "201": { description: "The campaign", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /campaigns/{id}:
    get:
      tags: [Campaigns]
      summary: "Campaign detail with its latest run and per-status recipient counts"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "The campaign and its run", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Campaigns]
      summary: "Edit a draft (marketer+)"
      description: "Only a draft, or a campaign that failed to start, can be edited; a scheduled campaign has to be cancelled first (409)."
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                name: { type: string }
                kind: { type: string, enum: [campaign, newsletter] }
                brand_id: { type: string }
                topic_id: { type: string }
                segment_id: { type: [string, "null"] }
                template_id: { type: [string, "null"] }
                layout_id: { type: [string, "null"] }
                from_name: { type: [string, "null"] }
                from_email: { type: [string, "null"] }
                reply_to: { type: [string, "null"] }
                throttle_per_min: { type: [integer, "null"], minimum: 1, maximum: 1000000 }
      responses:
        "200": { description: "The campaign", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
    delete:
      tags: [Campaigns]
      summary: "Archive a finished campaign (marketer+); its report is kept"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "204": { description: "Archived" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /campaigns/{id}/duplicate:
    post:
      tags: [Campaigns]
      summary: "Copy a campaign into a new draft (marketer+)"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
      responses:
        "201": { description: "The new draft", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /campaigns/{id}/test-send:
    post:
      tags: [Campaigns]
      summary: "Send the newest saved content as a test (marketer+)"
      description: "Marked as a test, uses one email credit per address, and records that the campaign was tested (a pre-flight warning clears for 24 hours)."
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, recipients]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                recipients: { type: array, items: { type: string } }
      responses:
        "200": { description: "{results[]}", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /campaigns/{id}/preflight:
    get:
      tags: [Campaigns]
      summary: "Checks before sending (marketer+)"
      description: "Blocking: sender domain not verified for marketing, content errors, empty audience, no credits, invalid sender or reply-to. Warnings: credits shorter than the audience, no test in the last 24 hours. Answers 200 with ok and problems[]; audience is an upper bound."
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "{ok, problems[], audience, findings[]}", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /campaigns/{id}/start:
    post:
      tags: [Campaigns]
      summary: "Send now, or schedule with scheduled_at (marketer+)"
      description: "Runs the pre-flight again and refuses (400, details.problems) if anything blocks. Snapshots the audience and begins sending. With a scheduled_at more than a minute away the campaign is scheduled and everything is checked again at that time."
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                scheduled_at: { type: string, format: date-time }
      responses:
        "200": { description: "{id, status}", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /campaigns/{id}/pause:
    post:
      tags: [Campaigns]
      summary: "Pause a running campaign (marketer+)"
      description: "Takes effect within one slice of the worker."
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
      responses:
        "200": { description: "{id, status}", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /campaigns/{id}/resume:
    post:
      tags: [Campaigns]
      summary: "Resume a paused campaign (marketer+)"
      description: "Continues with the recipients not yet sent to."
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
      responses:
        "200": { description: "{id, status}", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /campaigns/{id}/cancel:
    post:
      tags: [Campaigns]
      summary: "Stop a running or paused campaign, or unschedule one (marketer+)"
      description: "Messages already sent cannot be recalled; an unscheduled campaign becomes a draft again."
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
      responses:
        "200": { description: "{id, status}", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /campaigns/{id}/report:
    get:
      tags: [Campaigns]
      summary: "Results: messages, delivered, bounced, spam reports, unsubscribed, skipped"
      description: "Built from the campaign's sends and their delivery events, so delivery numbers lag provider callbacks. `engagement` splits first-party opens and clicks into human, mpp (Apple Mail Privacy Protection), bot (scanners) and unclassified (a provider's own tracking), each as {total, unique}; read the human numbers. `links` lists the most-clicked destinations by human clickers. `tracking` says whether opens and clicks are switched on for the campaign."
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }, { $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "{sends, events, tracking: {opens, clicks, brand_enabled}, engagement, recipients, delivered, bounced, complaints, unsubscribed, suppressed}", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /suppressions:
    get:
      tags: [Suppressions]
      summary: "List suppressions (owner/admin); look up by address without ever seeing it"
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }, { name: address, in: query, schema: { type: string } }, { name: channel, in: query, schema: { type: string } }, { name: reason, in: query, schema: { type: string } }, { name: include_removed, in: query, schema: { type: boolean } }, { name: limit, in: query, schema: { type: integer } }, { name: cursor, in: query, schema: { type: string } }]
      responses:
        "200": { description: "Suppressions, newest first", content: { application/json: { schema: { type: object } } } }
        "503": { description: "The Send API is not configured or not reachable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Suppressions]
      summary: "Add a manual or unsubscribed suppression (owner/admin)"
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, address, reason]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                address: { type: string }
                channel: { type: string }
                reason: { type: string, enum: [manual, unsubscribed] }
                brand: { type: string, description: "Brand id or slug; unsubscribed only" }
                note: { type: string }
      responses:
        "201": { description: "Created (200 if it already existed)", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /suppressions/resubscribe:
    post:
      tags: [Suppressions]
      summary: "Resubscribe an address (owner/admin): clear it from the connected email providers' own suppression lists and lift its suppressions here"
      description: |
        Mailgun, SendGrid, Postmark, SparkPost and Elastic Email keep their own bounce / complaint /
        unsubscribe lists and refuse to deliver to a listed address whatever MessagePipe's list says, so
        lifting a suppression alone does not get mail to the inbox. This takes the address off every active
        email provider's list (or the providers named in `provider_ids`), then lifts the workspace's own
        active suppressions for it. Providers that keep no list to clear are reported as `unsupported`.

        Each part is idempotent. One provider failing does not stop the others: the call returns 200 with
        `complete: false`, the per-provider outcomes, and (when the local lift failed) `local_error`.
        Sending the same request again finishes the job.

        Consent is not changed: someone who unsubscribed must still opt in again.
        The address is never stored; the audit trail records counts only.
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, address, reason]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                address: { type: string, maxLength: 320, description: "A bare email address" }
                reason: { type: string, minLength: 3, maxLength: 500 }
                channel: { type: string, enum: [email], default: email }
                provider_ids: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/Id" }, description: "Only these active email providers. Omit for all of them; an empty array skips the providers." }
                lift_local: { type: boolean, default: true, description: "Also lift the workspace's own suppressions for the address" }
      responses:
        "200":
          description: "What happened, per provider and locally"
          content:
            application/json:
              schema:
                type: object
                required: [providers, lifted, complete]
                properties:
                  providers:
                    type: array
                    items:
                      type: object
                      required: [provider_id, provider_type, display_name, status, lists]
                      properties:
                        provider_id: { $ref: "#/components/schemas/Id" }
                        provider_type: { type: string }
                        display_name: { type: string }
                        status: { type: string, enum: [cleared, not_listed, unsupported, failed] }
                        lists: { type: array, items: { type: string }, description: "Provider lists the address was taken off" }
                        detail: { type: string }
                        code: { type: string, description: "Machine code for a failure, e.g. auth_failed, rate_limited, blocked_recipient" }
                  lifted: { type: array, items: { type: object }, description: "The workspace suppressions that were lifted" }
                  complete: { type: boolean, description: "False when a provider failed or the local lift failed" }
                  local_error: { type: string, description: "Present when the local lift failed after the providers were cleared" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /suppressions/{id}:
    delete:
      tags: [Suppressions]
      summary: "Lift a suppression (owner/admin); soft delete with a mandatory reason"
      security: [{ sessionCookie: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { $ref: "#/components/schemas/Id" } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id, reason]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                reason: { type: string, minLength: 3, maxLength: 500 }
      responses:
        "200": { description: "The suppression, with who removed it and why", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /workspace-settings:
    get:
      tags: [Workspace settings]
      summary: "Suppression mode, transactional-on-complaint and unsubscribe scope (defaults when never set)"
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: "#/components/parameters/WorkspaceIdQuery" }]
      responses:
        "200": { description: "Settings", content: { application/json: { schema: { type: object } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Workspace settings]
      summary: "Change settings (owner/admin); audited with before and after"
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspace_id]
              properties:
                workspace_id: { $ref: "#/components/schemas/Id" }
                suppression_mode: { type: [string, "null"], enum: [off, shadow, enforce, null], description: "null follows the platform default" }
                block_transactional_on_complaint: { type: boolean }
                unsubscribe_scope: { type: string, enum: [brand, workspace] }
      responses:
        "200": { description: "Updated settings", content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
