openapi: 3.1.0

info:
  title: Spenza Partner API
  version: 3.0.0
  summary: Partner API for SIMs, eSIMs, plans, billing, users, groups and numbers — v1.1 surface.
  description: |
    The **Spenza Partner API** lets you manage SIMs, eSIMs, plans, subscriptions,
    billing, users, phone numbers, teams and device/SIM/user groups programmatically.

    All endpoints follow one consistent standard:

    - **Auth** — exchange your API `key` + `secret` for a bearer token, then send
      `Authorization: Bearer <token>` on every request.
    - **Envelopes** — success responses are `{ success, data, meta? }`; errors are
      `{ success: false, error: { code, message, details? } }`.
    - **Pagination** — list endpoints accept `page` (1-indexed) and `pageSize`
      (max 100); totals are returned in `meta`.
    - **Idempotency** — many write endpoints honor an optional `Idempotency-Key`
      request header so a retried call replays the original result instead of
      repeating the side effect.
    - **Async operations** — a handful of endpoints (eSIM provisioning, port-in,
      the `/api/v3/...` purchase variants, top-up) return `202` (`201` for
      top-up) with a `transactionId` + `statusEndpoint` instead of the finished
      resource. Poll
      `GET /api/v3/transactions/{transactionId}` (no auth required) until the
      transaction reaches a terminal status.

    One resource — **port-in submission** — is a documented exception: it lives at
    `POST /api/v3/port-in`, requires an Admin-tier account role rather than just a
    valid bearer token, and is **not** on the standard envelope (see that operation's
    description for its exact response shape).
  contact:
    name: Spenza API Support
    email: support@spenza.com
    url: https://spenza.com
  license:
    name: Proprietary
    url: https://spenza.com/terms
  x-logo:
    url: https://spenza.com/logo.png
    altText: Spenza

servers:
  - url: https://api.spenza.com
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Authentication
    description: Exchange API credentials for a bearer token.
  - name: SIMs
    description: List, inspect, assign and manage SIMs.
  - name: Subscriptions
    description: Plan subscriptions attached to SIMs.
  - name: Plans & Catalog
    description: Purchasable plans and SIM products.
  - name: eSIM
    description: Provision eSIMs and fetch activation QR / install status.
  - name: Billing
    description: Invoices, credit balance and top-ups.
  - name: Users
    description: End users (employees) on your account that SIMs/devices are assigned to.
  - name: Orders & Transactions
    description: Order history and financial transactions.
  - name: Numbers & Port-in
    description: Check port-in eligibility and port existing numbers into Spenza.
  - name: Messaging
    description: Send outbound SMS from a provisioned number.
  - name: Webhooks
    description: Register endpoints for operator SMS/voice events and inspect deliveries.
  - name: Notifications
    description: In-account notifications and delivery preferences.
  - name: Device Groups
    description: Organize devices into named groups with spend/data limits.
  - name: SIM Groups
    description: Organize SIMs into named groups with spend/data limits.
  - name: User Groups
    description: Organize users (departments) into named groups with spend/data limits.
  - name: Devices
    description: The device catalog (phones/tablets) and their assignment to users.
  - name: Team Members
    description: Admins who manage your Spenza account (distinct from Users/end-users).
  - name: Async Status
    description: Universal status poll for any asynchronous (v3) operation.

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Bearer token obtained from `POST /api/v1.1/auth/token`.

  parameters:
    Page:
      name: page
      in: query
      description: 1-indexed page number.
      required: false
      schema: { type: integer, minimum: 1, default: 1 }
    PageSize:
      name: pageSize
      in: query
      description: Items per page.
      required: false
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Any string unique to this logical request. On a retry with the same key
        and the same request body, the original response is replayed verbatim
        (with an `Idempotency-Replayed: true` response header) instead of the
        action repeating. Reusing the key with a **different** body or on a
        different route returns `409 IDEMPOTENCY_KEY_REUSED`. A replay attempted
        while the original call is still in flight returns `409 CONFLICT`.
        Optional — omit it and the endpoint behaves exactly as it would otherwise.
      schema: { type: string, example: "a1b2c3d4-idem-key-001" }
    Iccid:
      name: iccid
      in: path
      required: true
      description: SIM ICCID.
      schema: { type: string, example: "8901260853182965429" }

  schemas:
    ErrorEnvelope:
      type: object
      required: [success, error]
      properties:
        success: { type: boolean, example: false }
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string, example: SIM_NOT_FOUND }
            message: { type: string, example: We couldn't find a SIM with that ICCID on your account. }
            details:
              description: Present on some 4xx errors (for example a list of missing fields). Always absent on 5xx.
              type: object

  responses:
    Unauthorized:
      description: Missing, malformed or expired bearer token.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example: { success: false, error: { code: UNAUTHORIZED, message: Missing or invalid token. } }
    ValidationError:
      description: Required fields absent or a field failed a constraint.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["email must be a valid email address"] } } }
    RateLimited:
      description: |
        Rate limit exceeded for this endpoint (or the account-wide default of 120 req/min).
        Only `Retry-After` is present on this response — the `X-RateLimit-*`
        headers below are sent on successful (non-429) requests, not on the
        429 itself.
      headers:
        Retry-After: { description: Seconds until you can retry., schema: { type: integer } }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example: { success: false, error: { code: RATE_LIMITED, message: "ThrottlerException: Too Many Requests" } }
    IdempotencyKeyReused:
      description: The `Idempotency-Key` header was reused with a different request body, or against a different route.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example: { success: false, error: { code: IDEMPOTENCY_KEY_REUSED, message: This request was already submitted with different details — please use a new idempotency key. } }

paths:
  # ────────────────────────────── Authentication ──────────────────────────────
  /api/v1.1/auth/token:
    post:
      tags: [Authentication]
      summary: Exchange credentials for a token
      operationId: authToken
      description: |
        Exchange your API key and secret for a 1-hour bearer token. This endpoint
        itself requires no authentication — your key and secret *are* the
        credentials being verified.

        Note the response fields are **snake_case** (`access_token`, not
        `accessToken`) — this endpoint is deliberately OAuth2/RFC 6749-shaped so
        existing OAuth2 client libraries work against it. Every other endpoint in
        this API uses camelCase.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [key, secret]
              properties:
                key: { type: string, description: API key issued to your partner account., example: your-api-key }
                secret: { type: string, description: API secret paired with the key., example: your-api-secret }
      responses:
        '200':
          description: Token issued.
          content:
            application/json:
              example:
                success: true
                data:
                  access_token: "eyJhbGciOiJI…"
                  token_type: Bearer
                  expires_in: 3600
                  expires_at: "2026-07-31T12:00:00.000Z"
                meta: { timezone: UTC }
        '400':
          description: Missing or empty `key`/`secret`.
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["key should not be empty", "secret should not be empty"] } } }
        '401':
          description: Bad API key/secret.
          content:
            application/json:
              example: { success: false, error: { code: UNAUTHORIZED, message: Invalid API credentials. } }
        '403':
          description: Your account's plan doesn't include API access.
          content:
            application/json:
              example: { success: false, error: { code: FORBIDDEN, message: This feature is not included in your current plan. Please upgrade your plan to access this feature. } }
        '500':
          description: Token signing failed on Spenza's side.
          content:
            application/json:
              example: { success: false, error: { code: INTERNAL_ERROR, message: Something is wrong at our end. Our engineers are being notified. } }

  # ────────────────────────────────── SIMs ────────────────────────────────────
  /api/v1.1/sims:
    get:
      tags: [SIMs]
      summary: List SIMs
      operationId: listSims
      description: List the SIMs on your account, paginated, with live status and assignment.
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PageSize'
        - { name: status, in: query, schema: { type: string, enum: [ACTIVE, INACTIVE, EXPIRED] } }
        - { name: assignedStatus, in: query, schema: { type: string, enum: [ASSIGNED, UNASSIGNED] } }
        - { name: assignedTo, in: query, description: Filter by assigned user's email., schema: { type: string, format: email } }
        - { name: isEsim, in: query, schema: { type: boolean } }
        - { name: search, in: query, description: "Free-text match on ICCID, phone number, operator, network, assigned user email, device IMEI/model or group name.", schema: { type: string } }
      responses:
        '200':
          description: A page of SIMs.
          content:
            application/json:
              example:
                success: true
                data:
                  - iccid: "8901260853182965429"
                    phoneNumber: "+12792044584"
                    status: ACTIVE
                    assignedStatus: ASSIGNED
                    isEsim: false
                    operator: "AT&T"
                    network: "AT&T"
                    operatorStatus: "ACTIVATED"
                    assignedTo: { email: jane@example.com, name: Jane Doe }
                    device: { imei: "356938035643809", model: "iPhone 14" }
                    group: "Sales Team"
                    createdAt: "2026-06-01T10:00:00.000Z"
                meta: { page: 1, pageSize: 25, total: 240, totalPages: 10 }
        '400':
          description: A filter value failed validation.
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["assignedTo must be an email", "status must be a valid enum value"] } } }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1.1/sims/{iccid}:
    get:
      tags: [SIMs]
      summary: Get a SIM by ICCID
      operationId: getSim
      description: Get a single SIM by ICCID, account-scoped — another account's SIM reports the same 404 as a nonexistent one.
      parameters: [ { $ref: '#/components/parameters/Iccid' } ]
      responses:
        '200':
          description: The SIM.
          content:
            application/json:
              example:
                success: true
                data:
                  iccid: "8901260853182965429"
                  phoneNumber: "+12792044584"
                  status: ACTIVE
                  assignedStatus: ASSIGNED
                  isEsim: false
                  operator: "T-Mobile"
                  network: "T-Mobile"
                  operatorStatus: "ACTIVATED"
                  source: Other
                  activationCode: ""
                  assignedTo: { name: Jane Doe, email: jane@example.com }
                  activePlan: { productName: "T-Mobile 100", status: ACTIVE, endDate: "2026-08-21T00:00:00.000Z" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: No SIM with that ICCID on your account.
          content:
            application/json:
              example: { success: false, error: { code: SIM_NOT_FOUND, message: We couldn't find a SIM with that ICCID on your account. } }

  /api/v1.1/sims/{iccid}/usage:
    get:
      tags: [SIMs]
      summary: Get SIM usage
      operationId: getSimUsage
      description: |
        Data / voice / SMS usage for the SIM's current billing cycle. `data.used`
        is decimal GB (1 GB = 1e9 bytes) sourced from the invoice; `history` is
        `[]` unless `history=true`, and is empty even then for a SIM not covered
        by the usage-snapshot pipeline.
      parameters:
        - $ref: '#/components/parameters/Iccid'
        - { name: history, in: query, description: Include prior billing cycles., schema: { type: boolean, default: false } }
      responses:
        '200':
          description: Usage for the current cycle (and prior cycles if requested).
          content:
            application/json:
              example:
                success: true
                data:
                  iccid: "8901260853182965429"
                  cycle: { start: "2026-07-01T00:00:00.000Z", end: "2026-07-31T00:00:00.000Z" }
                  data: { used: 2.41, allowance: 5, unit: GB }
                  voice: { usedMinutes: 128, allowanceMinutes: 1000 }
                  sms: { used: 14, allowance: 500 }
                  history: []
        '400':
          description: "`history` must be a boolean."
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["history must be a boolean value"] } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: SIM not found, or SIM has no active subscription this cycle.
          content:
            application/json:
              examples:
                sim: { value: { success: false, error: { code: SIM_NOT_FOUND, message: We couldn't find a SIM with that ICCID on your account. } } }
                subscription: { value: { success: false, error: { code: SUBSCRIPTION_NOT_FOUND, message: This SIM doesn't have an active plan this billing cycle, so there's no usage to show. } } }

  /api/v1.1/sims/{iccid}/assign:
    post:
      tags: [SIMs]
      summary: Assign a SIM to a user
      operationId: assignSim
      description: |
        Assign or reassign the SIM to a user, identified by email. Reassigning an
        already-assigned SIM is supported. If no user exists for that email and
        `name` is supplied, the user is auto-created; if the user already exists
        and `name` differs, the profile name is synced best-effort.
      parameters:
        - $ref: '#/components/parameters/Iccid'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email, example: jane@example.com }
                name: { type: string, description: Required when no user exists yet for `email` — used to create their profile., example: Jane Doe }
      responses:
        '200':
          description: SIM assigned.
          content:
            application/json:
              example:
                success: true
                data:
                  iccid: "8901260853182965429"
                  assignedTo: { name: Jane Doe, email: jane@example.com }
        '400':
          description: Email missing, malformed, or no user exists and no `name` was supplied.
          content:
            application/json:
              examples:
                missing: { value: { success: false, error: { code: MISSING_FIELDS, message: Please enter an email address to assign this SIM. } } }
                malformed: { value: { success: false, error: { code: VALIDATION_ERROR, message: That doesn't look like a valid email address — please double-check it. } } }
                noUser: { value: { success: false, error: { code: MISSING_FIELDS, message: No user exists with that email. Include a name so we can create their profile and assign the SIM. } } }
        '404':
          description: SIM not found.
          content:
            application/json:
              example: { success: false, error: { code: SIM_NOT_FOUND, message: We couldn't find a SIM with that ICCID on your account. } }
        '409': { $ref: '#/components/responses/IdempotencyKeyReused' }

  /api/v1.1/sims/{iccid}/renew-number:
    post:
      tags: [SIMs]
      summary: Request a new phone number
      operationId: renewNumber
      description: Request a new phone number for the SIM. Rate-limited (a burst backstop — your plan's real monthly quota is enforced separately).
      parameters: [ { $ref: '#/components/parameters/Iccid' } ]
      responses:
        '200':
          description: Request placed. This is synchronous — a direct, blocking call to the operator — the new number itself is not returned.
          content:
            application/json:
              example: { success: true, data: { message: "Request placed successfully; a new number will be assigned soon." } }
        '400':
          description: >-
            The operator adapter rejected the request as a configuration or
            input problem — e.g. a portfolio/customer ID that couldn't be
            resolved for this account, or an operator-reported invalid IMEI or
            number.
          content:
            application/json:
              examples:
                config: { value: { success: false, error: { code: VALIDATION_ERROR, message: PortfolioId or CustomerId any one is required ! } } }
                invalidNumber: { value: { success: false, error: { code: VALIDATION_ERROR, message: Invalid number. } } }
                missing: { value: { success: false, error: { code: MISSING_FIELDS, message: A required field is missing. } } }
        '404':
          description: >-
            SIM not found, SIM has no active subscription, or the operator
            reported the plan/invoice/user behind this SIM couldn't be found.
          content:
            application/json:
              examples:
                sim: { value: { success: false, error: { code: SIM_NOT_FOUND, message: We couldn't find a SIM with that ICCID on your account. } } }
                subscription: { value: { success: false, error: { code: SUBSCRIPTION_NOT_FOUND, message: This SIM doesn't have an active plan, so there's no number to renew. } } }
                plan: { value: { success: false, error: { code: PLAN_NOT_FOUND, message: We couldn't find that plan. } } }
                invoice: { value: { success: false, error: { code: INVOICE_NOT_FOUND, message: We couldn't find that invoice. } } }
                user: { value: { success: false, error: { code: USER_NOT_FOUND, message: We couldn't find a user with that email. } } }
        '409':
          description: SIM's line isn't in a state that allows renewal right now (operator-dependent).
          content:
            application/json:
              example: { success: false, error: { code: CONFLICT, message: This SIM isn't in a state that allows a number renewal right now. } }
        '429': { $ref: '#/components/responses/RateLimited' }
        '501':
          description: The SIM's operator doesn't support number renewal.
          content:
            application/json:
              example: { success: false, error: { code: NOT_IMPLEMENTED, message: Number renewal is not available for this operator yet. } }
        '500':
          description: An unrecognized operator or renewal failure.
          content:
            application/json:
              example: { success: false, error: { code: INTERNAL_ERROR, message: The operation failed. Contact support with this transaction ID for details. } }

  # ────────────────────────────── Subscriptions ───────────────────────────────
  /api/v1.1/subscriptions:
    get:
      tags: [Subscriptions]
      summary: List subscriptions
      operationId: listSubscriptions
      description: |
        List subscriptions across the account, paginated. `status=ACTIVE`
        (the default) is widened to also include `PENDING` and
        `CANCEL_SCHEDULED` — the still-serving set. Every other filter value is
        an exact match.
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PageSize'
        - { name: status, in: query, schema: { type: string, enum: [ACTIVE, PENDING, SCHEDULED, CANCELLED], default: ACTIVE } }
      responses:
        '200':
          description: A page of subscriptions.
          content:
            application/json:
              example:
                success: true
                data:
                  - iccid: "8901260853182965429"
                    productName: "T-Mobile 100"
                    status: ACTIVE
                    startDate: "2026-07-01T00:00:00.000Z"
                    endDate: "2026-07-31T00:00:00.000Z"
                    autoTopUp: true
                    operator: "T-Mobile"
                    network: "T-Mobile"
                meta: { page: 1, pageSize: 25, total: 12, totalPages: 1 }
        '400':
          description: "`status` must be a valid enum value."
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["status must be one of the following values: ACTIVE, PENDING, SCHEDULED, CANCELLED"] } } }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1.1/sims/{iccid}/subscription:
    get:
      tags: [Subscriptions]
      summary: Get a SIM's subscription
      operationId: getSimSubscription
      description: |
        Get the subscription attached to a SIM. Falls back to the newest
        `SCHEDULED` subscription if there's no currently-serving one, so a
        subscription you can list is never a surprise 404 here.
      parameters: [ { $ref: '#/components/parameters/Iccid' } ]
      responses:
        '200':
          description: The subscription.
          content:
            application/json:
              example:
                success: true
                data:
                  iccid: "8901260853182965429"
                  productName: "T-Mobile 100"
                  planCode: "TMO0001"
                  status: ACTIVE
                  startDate: "2026-07-01T00:00:00.000Z"
                  endDate: "2026-07-31T00:00:00.000Z"
                  scheduledDate: null
                  autoTopUp: true
                  operator: "T-Mobile"
                  network: "T-Mobile"
                  orderId: "ord_1698765432100"
        '404':
          description: SIM has no subscription (also returned when the SIM itself doesn't exist).
          content:
            application/json:
              example: { success: false, error: { code: SUBSCRIPTION_NOT_FOUND, message: This SIM doesn't have a subscription yet. } }

  /api/v1.1/subscriptions/{iccid}/cancel:
    post:
      tags: [Subscriptions]
      summary: Cancel a subscription
      operationId: cancelSubscription
      description: |
        Cancel the SIM's subscription. Always cancels at end of the current
        billing period (`cancelNow: false` under the hood) — an `ACTIVE`
        subscription typically comes back `CANCEL_SCHEDULED`, not `CANCELLED`.
        Synchronous. Returns **`201`** (not `200`) on success.
      parameters:
        - $ref: '#/components/parameters/Iccid'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '201':
          description: Cancellation processed.
          content:
            application/json:
              example: { success: true, data: { iccid: "8901260853182965429", status: CANCEL_SCHEDULED, cancelledDate: "2026-08-01T00:00:00.000Z" } }
        '400':
          description: >-
            A Stripe-side validation or missing-field problem surfaced
            through the same failure classifier used by other
            transaction-based endpoints.
          content:
            application/json:
              examples:
                validation: { value: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.' } } }
                missingFields: { value: { success: false, error: { code: MISSING_FIELDS, message: A required field is missing. } } }
        '404':
          description: >-
            SIM has no subscription to cancel. Also returned when the SIM
            itself doesn't exist on your account, or when a cancellable
            subscription was found but is missing its Stripe billing
            linkage — all three cases share the same
            `SUBSCRIPTION_NOT_FOUND` code and are distinguishable only by
            message text.
          content:
            application/json:
              examples:
                noSim: { value: { success: false, error: { code: SUBSCRIPTION_NOT_FOUND, message: This SIM doesn't have a subscription yet. } } }
                noCancellable: { value: { success: false, error: { code: SUBSCRIPTION_NOT_FOUND, message: This SIM doesn't have a subscription to cancel. } } }
                noBillingLink: { value: { success: false, error: { code: SUBSCRIPTION_NOT_FOUND, message: "This subscription doesn't have a billing record on file, so it can't be cancelled right now. Please contact support." } } }
        '409':
          description: Subscription already cancelled/cancellation-scheduled, or the `Idempotency-Key` was reused with a different request.
          content:
            application/json:
              examples:
                alreadyCancelled: { value: { success: false, error: { code: CONFLICT, message: This subscription has already been cancelled. } } }
                idempotencyReused: { value: { success: false, error: { code: IDEMPOTENCY_KEY_REUSED, message: This request was already submitted with different details — please use a new idempotency key. } } }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ───────────────────────────── Plans & Catalog ──────────────────────────────
  /api/v1.1/plans:
    get:
      tags: [Plans & Catalog]
      summary: List plans
      operationId: listPlans
      description: |
        List the plans your account may purchase. `search` requires `searchType`
        to be set (otherwise the search matches nothing rather than erroring
        loudly). Omitting `category` restricts results to `MSP_PLAN` and
        `RESELLER_PLAN`; supplying it narrows to exactly that one category.

        Each plan optionally includes `tieredPricing` (only present when the
        plan's `planType` is `PAY_AS_YOU_GO`) and/or `volumeTierPricing` (only
        present when volume-tiered pricing is enabled for the plan).
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PageSize'
        - { name: search, in: query, schema: { type: string } }
        - { name: searchType, in: query, schema: { type: string, enum: [productName, productId] } }
        - { name: category, in: query, schema: { type: string, enum: [SPENZA_PLAN, MSP_PLAN, RESELLER_PLAN, ENDUSER_PLAN, SIM, SaaS] } }
      responses:
        '200':
          description: A page of plans.
          content:
            application/json:
              example:
                success: true
                data:
                  - productId: AT0001
                    productName: "AT&T 5GB"
                    description: "Unlimited talk & text + 5GB data"
                    price: 49.99
                    currency: usd
                    validityDays: 30
                    data: { value: 5, unit: GB }
                    voiceMinutes: 1000
                    smsCount: 500
                    operator: "AT&T"
                    network: "AT&T"
                    isEsim: false
                    planType: FLAT_RATE
                    recurring: true
                  - productId: PAYG001
                    productName: "Pay-As-You-Go Data"
                    description: "Pay-as-you-go data, voice & SMS"
                    price: 0
                    currency: usd
                    validityDays: 30
                    data: { value: null, unit: null }
                    voiceMinutes: 0
                    smsCount: 0
                    operator: "AT&T"
                    network: "AT&T"
                    isEsim: false
                    planType: PAY_AS_YOU_GO
                    recurring: false
                    tieredPricing:
                      dataTiers: [ { from: 0, to: 1, unit: GB, price: 5 }, { from: 1, to: null, unit: GB, price: 4 } ]
                      voiceTiers: [ { from: 0, to: 100, unit: min, price: 0.02 } ]
                      smsTiers: [ { from: 0, to: 100, unit: sms, price: 0.01 } ]
                    volumeTierPricing:
                      tiers: [ { minQuantity: 1, maxQuantity: 9, pricePerUnit: 49.99 }, { minQuantity: 10, maxQuantity: null, pricePerUnit: 44.99 } ]
                meta: { page: 1, pageSize: 25, total: 8, totalPages: 1 }
        '400':
          description: >-
            `search` was supplied without `searchType` (`MISSING_FIELDS`). Any
            other malformed query param — `page`/`pageSize` not coercible to an
            integer, an invalid `category`, or a malformed `searchType` value —
            returns the generic validation error instead. Note a malformed
            `searchType` value produces the *identical message text* as the
            `search`-without-`searchType` case above, but under
            `VALIDATION_ERROR` rather than `MISSING_FIELDS` — the two can only
            be told apart by `error.code`, not by message.
          content:
            application/json:
              examples:
                searchWithoutType: { value: { success: false, error: { code: MISSING_FIELDS, message: Please search by either plan name or plan ID. } } }
                validation: { value: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["page must not be less than 1"] } } } }

  /api/v1.1/plans/{planId}:
    get:
      tags: [Plans & Catalog]
      summary: Get a plan
      operationId: getPlan
      description: |
        Get a plan's details. `planId` is the plan's `productId`. Optionally
        includes `tieredPricing` (only when `planType` is `PAY_AS_YOU_GO`)
        and/or `volumeTierPricing` (only when volume-tiered pricing is enabled
        for the plan) — same conditional fields as the list endpoint.
      parameters: [ { name: planId, in: path, required: true, schema: { type: string, example: AT0001 } } ]
      responses:
        '200':
          description: The plan.
          content:
            application/json:
              examples:
                flatRate: { value: { success: true, data: { productId: AT0001, productName: "AT&T 5GB", description: "Unlimited talk & text + 5GB data", price: 49.99, currency: usd, validityDays: 30, data: { value: 5, unit: GB }, voiceMinutes: 1000, smsCount: 500, operator: "AT&T", network: "AT&T", isEsim: false, planType: FLAT_RATE, recurring: true } } }
                payAsYouGo: { value: { success: true, data: { productId: PAYG001, productName: "Pay-As-You-Go Data", description: "Pay-as-you-go data, voice & SMS", price: 0, currency: usd, validityDays: 30, data: { value: null, unit: null }, voiceMinutes: 0, smsCount: 0, operator: "AT&T", network: "AT&T", isEsim: false, planType: PAY_AS_YOU_GO, recurring: false, tieredPricing: { dataTiers: [ { from: 0, to: 1, unit: GB, price: 5 }, { from: 1, to: null, unit: GB, price: 4 } ], voiceTiers: [ { from: 0, to: 100, unit: min, price: 0.02 } ], smsTiers: [ { from: 0, to: 100, unit: sms, price: 0.01 } ] }, volumeTierPricing: { tiers: [ { minQuantity: 1, maxQuantity: 9, pricePerUnit: 49.99 }, { minQuantity: 10, maxQuantity: null, pricePerUnit: 44.99 } ] } } } }
        '404':
          description: No plan with that ID on your account.
          content:
            application/json:
              example: { success: false, error: { code: PLAN_NOT_FOUND, message: We couldn't find a plan with that ID. } }

  /api/v1.1/sim-products:
    get:
      tags: [Plans & Catalog]
      summary: List SIM products
      operationId: listSimProducts
      description: |
        List purchasable SIM products (ACTIVE only) — the physical/eSIM unit
        itself, separate from a data plan. Each SIM product optionally includes
        `volumeTierPricing` when volume-tiered pricing is enabled for it.
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: A page of SIM products.
          content:
            application/json:
              example:
                success: true
                data:
                  - simId: BIB009A
                    simName: "AT&T Physical SIM"
                    description: "Standard tri-cut physical SIM"
                    price: 5
                    currency: usd
                    operator: "AT&T"
                    network: "AT&T"
                    isEsim: false
                    inStock: 240
                    minOrderQuantity: 1
                    maxOrderQuantity: 10
                  - simId: BIB010A
                    simName: "AT&T eSIM"
                    description: "eSIM, bulk pricing available"
                    price: 5
                    currency: usd
                    operator: "AT&T"
                    network: "AT&T"
                    isEsim: true
                    inStock: 500
                    minOrderQuantity: 1
                    maxOrderQuantity: 1
                    volumeTierPricing:
                      tiers: [ { minQuantity: 1, maxQuantity: 49, pricePerUnit: 5 }, { minQuantity: 50, maxQuantity: null, pricePerUnit: 4.25 } ]
                meta: { page: 1, pageSize: 25, total: 4, totalPages: 1 }

        '400':
          description: "`page`/`pageSize` out of range."
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["pageSize must not be greater than 100"] } } }
  /api/v1.1/plans/purchase:
    post:
      tags: [Plans & Catalog]
      summary: Purchase a plan (synchronous)
      operationId: purchasePlan
      description: |
        Purchase / activate a plan for a SIM. Synchronous — blocks and returns
        the finished purchase. For an async variant, use
        `POST /api/v3/plans/purchase` instead (a different request/response
        contract, not an alias of this one).

        Throttled at 60 requests/60s specific to this route, tighter than the
        120 req/60s account-wide default. A `429` can also occur when the
        purchase for this exact SIM+plan is momentarily lock-contended by a
        concurrent request — same `RATE_LIMITED` code and status, but with the
        message "Too many concurrent purchase requests for this account —
        please retry in a moment." rather than a generic throttle message.
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [iccid, productName, activateNow]
              properties:
                iccid: { type: string, example: "8901260853182965429" }
                productName: { type: string, example: "AT&T 5GB" }
                activateNow: { type: boolean, description: No default — must be sent explicitly., example: true }
                scheduleDate: { type: string, description: "YYYY-MM-DD; required when activateNow=false.", example: "2026-08-01" }
      responses:
        '201':
          description: Purchased.
          content:
            application/json:
              example:
                success: true
                data: { iccid: "8901260853182965429", productName: "AT&T 5GB", status: ACTIVE, startDate: "2026-07-30T00:00:00.000Z", endDate: "2026-08-30T00:00:00.000Z", orderId: "ORD-20260730-0001" }
        '400':
          description: Missing required fields, missing scheduleDate, or malformed input.
          content:
            application/json:
              examples:
                missing: { value: { success: false, error: { code: MISSING_FIELDS, message: Please fill in all the required fields to purchase this plan. } } }
                noSchedule: { value: { success: false, error: { code: MISSING_FIELDS, message: Please choose a start date for a plan you're scheduling for later. } } }
        '404':
          description: SIM or plan not found.
          content:
            application/json:
              examples:
                sim: { value: { success: false, error: { code: SIM_NOT_FOUND, message: We couldn't find a SIM with that ICCID on your account. } } }
                plan: { value: { success: false, error: { code: PLAN_NOT_FOUND, message: We couldn't find that plan. } } }
        '409':
          description: >-
            SIM already has an active plan this billing cycle, a concurrent
            purchase for the same SIM+plan raced this one, or the
            `Idempotency-Key` header was reused.
          content:
            application/json:
              examples:
                activePlan: { value: { success: false, error: { code: CONFLICT, message: This SIM already has an active plan this billing cycle. } } }
                concurrentRace: { value: { success: false, error: { code: CONFLICT, message: "This purchase conflicts with another in-flight request for this SIM — check the order status before retrying." } } }
                idempotencyKeyReused: { value: { success: false, error: { code: IDEMPOTENCY_KEY_REUSED, message: This request was already submitted with different details — please use a new idempotency key. } } }
                idempotencyKeyInFlight: { value: { success: false, error: { code: CONFLICT, message: "This request is already being processed — please retry in a moment." } } }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500':
          description: Purchase couldn't be completed or confirmed on Spenza's side.
          content:
            application/json:
              examples:
                purchaseFailed: { value: { success: false, error: { code: INTERNAL_ERROR, message: "We weren't able to complete this purchase — check the order status for details, or contact support if it keeps happening." } } }
                confirmationFailed: { value: { success: false, error: { code: INTERNAL_ERROR, message: "We weren't able to confirm this purchase — check the order status for details, or contact support if it keeps happening." } } }

  /api/v3/plans/purchase:
    post:
      tags: [Plans & Catalog]
      summary: Purchase a plan (asynchronous)
      operationId: purchasePlanAsync
      description: |
        Purchase / activate a plan for a SIM, asynchronously — always `202`
        with a `transactionId` to poll. A different request contract from
        `POST /api/v1.1/plans/purchase`: `activateNow` defaults from whether
        `scheduleDate` is set (rather than being required), `scheduleDate` is a
        full ISO 8601 datetime (not `YYYY-MM-DD`), and this endpoint accepts an
        optional free-form `metaData` object stored on the transaction.

        `202` only means the request was accepted for processing, not that the
        purchase succeeded — validation performed after acceptance (bad ICCID,
        plan not found, pricing not configured, etc.) is only visible by
        polling `statusEndpoint`. Throttled at 60 requests/60s specific to this
        route, tighter than the 120 req/60s account-wide default.
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [iccid, productName]
              properties:
                iccid: { type: string, example: "8901260853182965429" }
                productName: { type: string, example: "AT&T 5GB" }
                activateNow: { type: boolean, description: "Defaults to !scheduleDate when omitted.", example: true }
                scheduleDate: { type: string, format: date-time, description: "Full ISO 8601 datetime; required when activateNow=false.", example: "2026-08-01T00:00:00Z" }
                metaData: { type: object, description: Free-form partner metadata stored on the transaction., example: { source: web, campaignId: "123" } }
      responses:
        '202':
          description: Accepted — poll `statusEndpoint`.
          content:
            application/json:
              example: { success: true, data: { transactionId: "64f1a2b3c4d5e6f7a8b9c0d1", statusEndpoint: "/api/v3/transactions/64f1a2b3c4d5e6f7a8b9c0d1" } }
        '400':
          description: Missing fields, both activateNow=true and scheduleDate set, or missing scheduleDate when scheduling.
          content:
            application/json:
              examples:
                missing: { value: { success: false, error: { code: MISSING_FIELDS, message: Please fill in all the required fields to purchase this plan. } } }
                bothSet: { value: { success: false, error: { code: VALIDATION_ERROR, message: A plan can't both start now and be scheduled — remove the start date, or set activateNow to false. } } }
                noSchedule: { value: { success: false, error: { code: MISSING_FIELDS, message: Please choose a start date for a plan you're scheduling for later. } } }
        '409':
          description: The `Idempotency-Key` header was reused.
          content:
            application/json:
              examples:
                idempotencyKeyReused: { value: { success: false, error: { code: IDEMPOTENCY_KEY_REUSED, message: This request was already submitted with different details — please use a new idempotency key. } } }
                idempotencyKeyInFlight: { value: { success: false, error: { code: CONFLICT, message: "This request is already being processed — please retry in a moment." } } }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ───────────────────────────────── eSIM ─────────────────────────────────────
  /api/v1.1/esim:
    post:
      tags: [eSIM]
      summary: Provision an eSIM, optionally with a plan
      operationId: provisionEsim
      description: |
        Provision an eSIM. Omit `planId` for a bare eSIM (SIM-only provisioning
        is only supported for the SpenzaJ carrier family today — other carriers
        return `501` on the SIM-only path); include `planId` to also activate a
        plan in the same operation. Always asynchronous — `202` with a
        `transactionId`/`statusEndpoint` to poll. On completion, `result` is
        `{ mdn, iccid, qrCode }` (or `{ orderId }` if those aren't available).
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [imei, simId]
              properties:
                imei: { type: string, description: "Device IMEI — exactly 15 digits, Luhn-valid.", example: "356938035643809" }
                simId: { type: string, description: SIM provisioning product name., example: BIB009A }
                planId: { type: string, description: "Optional plan product name — activates a plan in the same operation." }
                email: { type: string, description: "Shorthand for user.email. If omitted with no planId, the eSIM is provisioned unassigned." }
                activateNow: { type: boolean, description: "No server-enforced default despite historical docs claiming `true` — omitting it is forwarded as-is to the provisioning job rather than coerced." }
                scheduleDate: { type: string, format: date-time, description: "Only meaningful with planId; forces activateNow=false when set." }
                zipcode: { type: string, description: "5-digit US ZIP, overrides number provisioning ZIP.", example: "10011" }
                user:
                  type: object
                  description: Used to create the user if one doesn't exist for the resolved email.
                  required: [name]
                  properties:
                    name: { type: string, example: Jane Doe }
                    email: { type: string, format: email, example: jane@example.com }
                    phoneNumber: { type: string, example: "2031134322" }
                    address: { type: string, example: "47 W 13th St" }
                    city: { type: string, example: "New York" }
                    state: { type: string, example: "New York" }
                    zipcode: { type: string, example: "10011" }
      responses:
        '202':
          description: Accepted — poll `statusEndpoint`.
          content:
            application/json:
              example: { success: true, data: { transactionId: "64f1a2b3c4d5e6f7a8b9c0d1", statusEndpoint: "/api/v3/transactions/64f1a2b3c4d5e6f7a8b9c0d1" } }
        '400':
          description: Missing fields, invalid IMEI, or invalid nested `user`/`zipcode` fields.
          content:
            application/json:
              examples:
                missing: { value: { success: false, error: { code: MISSING_FIELDS, message: Please provide the device IMEI and SIM product. } } }
                imei: { value: { success: false, error: { code: VALIDATION_ERROR, message: That IMEI doesn't look valid — please double-check the device's IMEI number. } } }
        '404':
          description: SIM product or plan not found.
          content:
            application/json:
              examples:
                sim: { value: { success: false, error: { code: SIM_NOT_FOUND, message: We couldn't find that eSIM product. } } }
                plan: { value: { success: false, error: { code: PLAN_NOT_FOUND, message: We couldn't find that plan. } } }
        '409':
          description: The `Idempotency-Key` header was reused with a different request body/route, or reused while the original request was still in flight.
          content:
            application/json:
              examples:
                idempotencyReused: { value: { success: false, error: { code: IDEMPOTENCY_KEY_REUSED, message: This request was already submitted with different details — please use a new idempotency key. } } }
                conflict: { value: { success: false, error: { code: CONFLICT, message: This request is already being processed with that idempotency key — please wait for it to finish before retrying. } } }
        '429': { $ref: '#/components/responses/RateLimited' }
        '501':
          description: SIM-only provisioning isn't supported for this operator (no `planId` given).
          content:
            application/json:
              example: { success: false, error: { code: NOT_IMPLEMENTED, message: "SIM-only provisioning is not currently available for this operator (Boom). Provide a planId to purchase a plan alongside the eSIM." } }

  /api/v1.1/esim/{iccid}/qr:
    get:
      tags: [eSIM]
      summary: Get eSIM activation QR
      operationId: getEsimQr
      description: |
        Re-fetch the eSIM's activation QR image URL and activation code.
        Deliberately does not regenerate/rotate the eSIM profile on a live
        carrier — it's a read, not a mutation — so `activationCode` may be
        `null` if it was never persisted.
      parameters: [ { $ref: '#/components/parameters/Iccid' } ]
      responses:
        '200':
          description: QR + activation code.
          content:
            application/json:
              example: { success: true, data: { iccid: "89012345678901234567", qrCodeUrl: "https://s3.amazonaws.com/esim-activation-code/…/qrcode.png", activationCode: "LPA:1$smdp.example.com$ABCD-1234" } }
        '400':
          description: SIM is physical, not an eSIM.
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: This is a physical SIM, not an eSIM, so there's no QR code available. } }
        '404':
          description: SIM not found.
          content:
            application/json:
              example: { success: false, error: { code: SIM_NOT_FOUND, message: We couldn't find a SIM with that ICCID on your account. } }

  # ─────────────────────────────── Billing ────────────────────────────────────
  /api/v1.1/invoices:
    get:
      tags: [Billing]
      summary: List invoices
      operationId: listInvoices
      description: |
        List invoices, paginated, with date filters. `from`/`to` are **date-only**
        (`YYYY-MM-DD`) here — unlike Orders/Transactions, which take full ISO
        datetimes.
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PageSize'
        - { name: from, in: query, schema: { type: string, format: date, example: "2026-06-01" } }
        - { name: to, in: query, schema: { type: string, format: date, example: "2026-06-30" } }
        - { name: status, in: query, schema: { type: string, enum: [paid, open, void] } }
      responses:
        '200':
          description: A page of invoices.
          content:
            application/json:
              example:
                success: true
                data:
                  - id: "64f1a2b3c4d5e6f7a8b9c0d1"
                    invoiceNumber: INV-2026-0001
                    periodStart: "2026-06-01"
                    periodEnd: "2026-06-30"
                    amount: 49.99
                    currency: usd
                    status: paid
                    pdfAvailable: true
                meta: { page: 1, pageSize: 25, total: 12, totalPages: 1 }
        '400':
          description: Bad date range, or an invalid `status`/`page`/`pageSize`/`from`/`to` value. This endpoint applies its own strict validation pipe (the only one in the API that does), so malformed query params are rejected before the handler runs.
          content:
            application/json:
              examples:
                dateOrder: { value: { success: false, error: { code: VALIDATION_ERROR, message: Please check your date range — the start date must be before the end date. } } }
                invalidParam: { value: { success: false, error: { code: VALIDATION_ERROR, message: "status must be a valid enum value" } } }

  /api/v1.1/invoices/{id}:
    get:
      tags: [Billing]
      summary: Get an invoice
      operationId: getInvoice
      description: Get a single invoice's full detail, line items, and usage breakdown.
      parameters: [ { name: id, in: path, required: true, schema: { type: string, example: "64f1a2b3c4d5e6f7a8b9c0d1" } } ]
      responses:
        '200':
          description: The invoice.
          content:
            application/json:
              example:
                success: true
                data:
                  id: "64f1a2b3c4d5e6f7a8b9c0d1"
                  invoiceNumber: INV-2026-0001
                  periodStart: "2026-06-01"
                  periodEnd: "2026-06-30"
                  amount: 49.99
                  currency: usd
                  status: paid
                  pdfAvailable: true
                  lineItems:
                    - { description: "AT&T 5GB", quantity: 1, amount: 49.99 }
                  usageSummary: { data: { used: 4.8, unit: GB }, voiceMinutes: 210, sms: 30 }
        '404':
          description: Invoice not found (also returned for a malformed id).
          content:
            application/json:
              example: { success: false, error: { code: INVOICE_NOT_FOUND, message: We couldn't find that invoice. } }

  /api/v1.1/invoices/{id}/pdf:
    get:
      tags: [Billing]
      summary: Download invoice PDF
      operationId: getInvoicePdf
      description: |
        On success, returns a `302` redirect to the PDF URL — **no JSON envelope
        on the 302 itself**, this is the one endpoint that writes a raw HTTP
        response instead of the standard envelope. Error responses are still the
        standard JSON envelope.

        Resolution precedence, in order: (1) a previously-stored S3 copy of the
        invoice PDF; (2) if a Stripe invoice PDF and invoice id are both on
        file, persist it to S3 now and use that URL, falling back to (3) the
        raw Stripe invoice PDF URL if that persist attempt fails; (4) the
        Stripe receipt URL; (5) the raw Stripe invoice PDF URL alone (no
        invoice id on file). The Stripe fallback URLs are temporary/time-limited
        by Stripe.
      parameters: [ { name: id, in: path, required: true, schema: { type: string, example: "64f1a2b3c4d5e6f7a8b9c0d1" } } ]
      responses:
        '302': { description: Redirect to the PDF URL (Location header). No response body. }
        '404':
          description: Invoice not found, or no downloadable copy exists.
          content:
            application/json:
              examples:
                notFound: { value: { success: false, error: { code: INVOICE_NOT_FOUND, message: We couldn't find that invoice. } } }
                noPdf: { value: { success: false, error: { code: INVOICE_NOT_FOUND, message: We couldn't find a downloadable copy of that invoice. } } }

  /api/v1.1/credit-balance:
    get:
      tags: [Billing]
      summary: Get credit balance
      operationId: getCreditBalance
      description: Available prepaid credit balance by currency.
      responses:
        '200':
          description: Credit balance.
          content:
            application/json:
              example: { success: true, data: { creditBalance: [ { currency: USD, amount: 150.75 }, { currency: EUR, amount: 20.00 } ] } }
        '404':
          description: Account has no billing customer on file.
          content:
            application/json:
              example: { success: false, error: { code: NOT_FOUND, message: Customer not found. } }

  /api/v1.1/top-up:
    post:
      tags: [Billing]
      summary: Add prepaid credit
      operationId: topUp
      description: |
        Create a hosted Stripe checkout link to add prepaid credit. `amount` is
        in the currency's **major unit** (e.g. `100` = $100.00). The returned
        `transactionId` moves to `COMPLETED`/`FAILED` once Stripe's checkout
        webhook fires — poll `statusEndpoint`, not this call, for the outcome.
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, currency]
              properties:
                amount: { type: number, description: "Major currency unit, must be > 0.", example: 100 }
                currency: { type: string, description: "ISO-4217 3-letter code, case-insensitive.", example: usd }
                returnUrl: { type: string, description: "Used as both the success and cancel URL. Defaults to https://app.spenza.com/account." }
      responses:
        '201':
          description: Checkout link created.
          content:
            application/json:
              example:
                success: true
                data:
                  checkoutUrl: "https://checkout.stripe.com/c/pay/cs_test_a1B2c3D4e5F6G7H8I9J0"
                  transactionId: "64f1a2b3c4d5e6f7a8b9c0d1"
                  statusEndpoint: "/api/v3/transactions/64f1a2b3c4d5e6f7a8b9c0d1"
        '400':
          description: |
            Missing/invalid amount or currency, or account has no Stripe customer yet.
            Note: the app-wide validation pipe validates `amount`/`currency`/`returnUrl`
            before this handler runs, so a present-but-invalid value actually surfaces
            the auto-generated, joined class-validator message shown in the `invalid`
            example below — not a friendlier hand-written string. The `missing` example
            only fires when `amount`/`currency` are absent entirely.
          content:
            application/json:
              examples:
                missing: { value: { success: false, error: { code: MISSING_FIELDS, message: Please enter an amount and currency to add credit. } } }
                invalid: { value: { success: false, error: { code: VALIDATION_ERROR, message: "amount must be greater than 0; currency must be a supported currency code" } } }
        '409':
          description: The `Idempotency-Key` was reused with a different request, or a replay was received while the original request is still in flight.
          content:
            application/json:
              examples:
                idempotencyReused: { value: { success: false, error: { code: IDEMPOTENCY_KEY_REUSED, message: This request was already submitted with different details — please use a new idempotency key. } } }
                inFlight: { value: { success: false, error: { code: CONFLICT, message: This request is already being processed. } } }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ──────────────────────────────── Users ─────────────────────────────────────
  /api/v1.1/users:
    get:
      tags: [Users]
      summary: List users
      operationId: listUsers
      description: List users (end users / employees) on your account, paginated.
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PageSize'
        - { name: search, in: query, description: "Case-insensitive match on name, email, or phone number.", schema: { type: string } }
        - { name: department, in: query, description: "Department name or id. An unknown department returns an empty page, not a 404.", schema: { type: string } }
      responses:
        '200':
          description: A page of users.
          content:
            application/json:
              example:
                success: true
                data:
                  - name: Jane Doe
                    email: jane@example.com
                    phoneNumber: "+12031134322"
                    department: Engineering
                    status: ACTIVE
                meta: { page: 1, pageSize: 25, total: 42, totalPages: 2 }
        '400':
          description: "`search`/`department` too long."
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["search must be shorter than or equal to 100 characters"] } } }
    post:
      tags: [Users]
      summary: Create a user
      operationId: createUser
      description: Create a user under your account. `department` is created automatically if it doesn't already exist.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, email]
              properties:
                name: { type: string, example: Jane Doe }
                email: { type: string, format: email, example: jane@example.com }
                phoneNumber: { type: string }
                address: { type: string }
                city: { type: string }
                state: { type: string }
                zipcode: { type: string }
                department: { type: string }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              example: { success: true, data: { name: Jane Doe, email: jane@example.com, phoneNumber: null, department: Engineering, status: ACTIVE } }
        '400':
          description: Missing name/email, or invalid field values.
          content:
            application/json:
              examples:
                missingFields: { value: { success: false, error: { code: MISSING_FIELDS, message: Please provide a name and email address for this user. } } }
                validationError: { value: { success: false, error: { code: VALIDATION_ERROR, message: Please check the name and email address you entered. } } }
        '409':
          description: A user with this email already exists on your account.
          content:
            application/json:
              example: { success: false, error: { code: CONFLICT, message: A user with this email already exists on your account. } }

  /api/v1.1/users/{email}:
    parameters: [ { name: email, in: path, required: true, description: "User's email address — the only identifier this API exposes for a user.", schema: { type: string, format: email, example: jane@example.com } } ]
    get:
      tags: [Users]
      summary: Get a user
      operationId: getUser
      description: Get a user by email. A malformed email in the path is treated as not-found, not a validation error.
      responses:
        '200':
          description: The user.
          content:
            application/json:
              example: { success: true, data: { name: Jane Doe, email: jane@example.com, phoneNumber: "+12031134322", department: Engineering, status: ACTIVE } }
        '404':
          description: No user with that email.
          content:
            application/json:
              example: { success: false, error: { code: USER_NOT_FOUND, message: We couldn't find a user with that email. } }
    put:
      tags: [Users]
      summary: Update a user
      operationId: updateUser
      description: Update a user. Any subset of the mutable fields — `email` itself is not updatable. Passing an empty `department` clears it.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                phoneNumber: { type: string }
                address: { type: string }
                city: { type: string }
                state: { type: string }
                zipcode: { type: string }
                department: { type: string }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              example: { success: true, data: { name: Jane Doe, email: jane@example.com, phoneNumber: "+12031134322", department: Engineering, status: ACTIVE } }
        '400':
          description: No updatable fields supplied, or invalid values.
          content:
            application/json:
              examples:
                missingFields: { value: { success: false, error: { code: MISSING_FIELDS, message: Please provide at least one field to update. } } }
                validationError: { value: { success: false, error: { code: VALIDATION_ERROR, message: "One of the fields you entered isn't valid — please check and try again." } } }
        '404':
          description: No user with that email.
          content:
            application/json:
              example: { success: false, error: { code: USER_NOT_FOUND, message: We couldn't find a user with that email. } }
    delete:
      tags: [Users]
      summary: Delete a user
      operationId: deleteUser
      description: Remove a user. Fails if the user still has devices assigned, or is the account's default user.
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              example: { success: true, data: { email: jane@example.com, status: DELETED } }
        '404':
          description: No user with that email.
          content:
            application/json:
              example: { success: false, error: { code: USER_NOT_FOUND, message: We couldn't find a user with that email. } }
        '409':
          description: User still has devices assigned, or is the account's default user.
          content:
            application/json:
              examples:
                devices: { value: { success: false, error: { code: CONFLICT, message: This user still has devices assigned. Please unassign them before removing the user. } } }
                default: { value: { success: false, error: { code: CONFLICT, message: This is the account's default user and can't be removed. } } }

  # ──────────────────────── Orders & Transactions ─────────────────────────────
  /api/v1.1/orders:
    get:
      tags: [Orders & Transactions]
      summary: List orders
      operationId: listOrders
      description: |
        List order history, paginated. `from`/`to` are **full ISO 8601
        datetimes** here (unlike Invoices' date-only filters) — a bare date
        parses to UTC midnight, so `from == to` collapses the range to a single
        instant; set `to` to end-of-day explicitly.
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PageSize'
        - { name: from, in: query, schema: { type: string, format: date-time, example: "2026-07-01T00:00:00Z" } }
        - { name: to, in: query, schema: { type: string, format: date-time, example: "2026-07-31T23:59:59Z" } }
        - { name: type, in: query, schema: { type: string, enum: [PLAN_PURCHASE, SIM_PURCHASE, SAAS_PURCHASE, OTHER] } }
        - { name: status, in: query, schema: { type: string, enum: [PENDING, COMPLETED, SHIPPED, DELIVERED, FAILED] } }
      responses:
        '200':
          description: A page of orders.
          content:
            application/json:
              example:
                success: true
                data:
                  - id: ord_1698765432100
                    type: PLAN_PURCHASE
                    status: COMPLETED
                    amount: 49.99
                    currency: usd
                    iccid: "8901260853182965429"
                    createdAt: "2026-07-01T10:00:00.000Z"
                meta: { page: 1, pageSize: 25, total: 30, totalPages: 2 }
        '400':
          description: Query validation failed, or an unexpected database error occurred while listing orders (the latter is surfaced as a 400 VALIDATION_ERROR too, with the raw internal error message passed through unmodified rather than genericized).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              examples:
                validation: { value: { success: false, error: { code: VALIDATION_ERROR, message: 'from must be a valid ISO 8601 date', details: { validationErrors: ["from must be a valid ISO 8601 date"] } } } }
                databaseError: { value: { success: false, error: { code: VALIDATION_ERROR, message: "<raw underlying database error message, passed through unmodified>" } } }

  /api/v1.1/orders/{id}:
    get:
      tags: [Orders & Transactions]
      summary: Get an order
      operationId: getOrder
      description: Get an order and the transactions raised against it (embedded, capped at 100 — use `GET /api/v1.1/transactions` for the full paginated history).
      parameters: [ { name: id, in: path, required: true, schema: { type: string, example: ord_1698765432100 } } ]
      responses:
        '200':
          description: The order.
          content:
            application/json:
              example:
                success: true
                data:
                  id: ord_1698765432100
                  type: PLAN_PURCHASE
                  status: COMPLETED
                  amount: 49.99
                  currency: usd
                  iccid: "8901260853182965429"
                  createdAt: "2026-07-01T10:00:00.000Z"
                  transactions:
                    - { id: txn_5c9a1e, amount: 49.99, currency: usd, status: SUCCEEDED, createdAt: "2026-07-01T10:00:01.000Z" }
        '400':
          description: An unexpected database error occurred while resolving this order's ICCID (surfaced as 400 VALIDATION_ERROR with the raw internal error message passed through unmodified, rather than genericized).
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: "<raw underlying database error message, passed through unmodified>" } }
        '404':
          description: Order not found (also returned for another account's order).
          content:
            application/json:
              example: { success: false, error: { code: NOT_FOUND, message: We couldn't find that order on your account. } }
        '500':
          description: An unexpected database error occurred while fetching the order or its transactions.
          content:
            application/json:
              example: { success: false, error: { code: INTERNAL_ERROR, message: Something went wrong on our end. Please contact Spenza support if this continues. } }

  /api/v1.1/transactions:
    get:
      tags: [Orders & Transactions]
      summary: List transactions
      operationId: listTransactions
      description: |
        Financial transaction history, paginated — charges and refunds. `type`
        is derived from the sign of `amount` (`REFUND` is negative), and
        `status` here excludes `OTHER` as a filter value even though it can
        appear in a response. `from`/`to` are full ISO 8601 datetimes, same
        same-day-collapse caveat as Orders.
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PageSize'
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
        - { name: type, in: query, schema: { type: string, enum: [CHARGE, REFUND] } }
        - { name: status, in: query, schema: { type: string, enum: [PENDING, SUCCEEDED, FAILED, REFUNDED] } }
      responses:
        '200':
          description: A page of transactions.
          content:
            application/json:
              example:
                success: true
                data:
                  - id: txn_5c9a1e
                    type: CHARGE
                    status: SUCCEEDED
                    amount: 49.99
                    currency: usd
                    orderId: ord_1698765432100
                    createdAt: "2026-07-01T10:00:01.000Z"
                meta: { page: 1, pageSize: 25, total: 60, totalPages: 3 }
        '400':
          description: A filter value failed validation.
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["type must be one of the following values: CHARGE, REFUND"] } } }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500':
          description: An unexpected error occurred — including a malformed/missing account identifier from the auth layer, which surfaces as a generic internal error rather than an auth-specific one.
          content:
            application/json:
              example: { success: false, error: { code: INTERNAL_ERROR, message: Something went wrong on our end. Please contact Spenza support if this continues. } }

  # ─────────────────────────── Numbers & Port-in ──────────────────────────────
  /api/v1.1/port-ins:
    get:
      tags: [Numbers & Port-in]
      summary: List port-in requests
      operationId: listPortIns
      description: List port-in requests and their status, paginated.
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PageSize'
        - { name: status, in: query, schema: { type: string, enum: [PENDING, IN_PROGRESS, COMPLETED, FAILED] } }
      responses:
        '200':
          description: A page of port-in requests.
          content:
            application/json:
              example:
                success: true
                data:
                  - portInNumber: "+15555551234"
                    status: IN_PROGRESS
                    iccid: "8901260853182965429"
                    submittedAt: "2026-07-05T10:00:00.000Z"
                meta: { page: 1, pageSize: 25, total: 3, totalPages: 1 }

        '400':
          description: "`status` must be a valid enum value."
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["status must be one of the following values: PENDING, IN_PROGRESS, COMPLETED, FAILED"] } } }
  /api/v1.1/port-in/eligibility:
    post:
      tags: [Numbers & Port-in]
      summary: Check port-in eligibility
      operationId: checkPortInEligibility
      description: |
        Check whether a number is eligible for port-in without submitting one —
        read-only, no order created. An ineligible number is a `200` with
        `eligible: false`, not an HTTP error. Only the **SpenzaJ** carrier
        actually implements this check today; every other carrier (Simetry,
        Soracom, EsimGo, CiscoJasper, Granite, Webbing, Boom) returns `501`.
      security: [ { bearerAuth: [] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [portInNumber]
              properties:
                portInNumber: { type: string, description: E.164 format., example: "+15555551234" }
                plan: { type: string, description: "Plan/product name — operator+network derived from it. Takes precedence over Operator/network if both are sent.", example: BIB009A }
                Operator: { type: string, description: Required when plan is absent., example: SpenzaJ }
                network: { type: string, description: Required when plan is absent., example: "T-Mobile" }
      responses:
        '200':
          description: Eligibility result.
          content:
            application/json:
              example: { success: true, data: { eligible: true, currentCarrier: "T-Mobile", numberType: Mobile, message: "Number is eligible for port-in" } }
        '400':
          description: Missing number/plan/operator, malformed number, or plan not found.
          content:
            application/json:
              examples:
                missingFields: { value: { success: false, error: { code: MISSING_FIELDS, message: Please provide the phone number and either a plan or operator/network., details: { required: ["portInNumber", "plan OR (Operator AND network)"] } } } }
                validationError: { value: { success: false, error: { code: VALIDATION_ERROR, message: "That phone number doesn't look valid — please check the format.", details: { field: portInNumber, expected: "E.164, e.g. +15555551234" } } } }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500':
          description: The carrier's eligibility check failed unexpectedly (a genuine upstream failure, not an unsupported carrier).
          content:
            application/json:
              example: { success: false, error: { code: INTERNAL_ERROR, message: "We couldn't check that number right now — please try again shortly." } }
        '501':
          description: This carrier doesn't support eligibility checks (most carriers today).
          content:
            application/json:
              example: { success: false, error: { code: NOT_IMPLEMENTED, message: "Eligibility checks aren't supported for this carrier yet." } }

  /api/v3/port-in:
    post:
      tags: [Numbers & Port-in]
      summary: Submit a port-in
      operationId: createPortIn
      description: |
        **The only port-in submission endpoint** — there is no `POST /api/v1.1/port-ins`.
        Move an existing number from another carrier into Spenza. Always
        asynchronous.

        **Two contract exceptions worth calling out:**
        1. **Requires an account role**, not just a valid bearer token —
           `Admin`, `Super Admin`, or `Standard Admin`.
        2. **Response is not on the standard envelope.** Success is a flat
           `{ success, transactionId, statusEndpoint, message, timestamp }` —
           no `data` wrapper — and `statusEndpoint` points at the **singular**
           legacy path `/api/v3/transaction/{id}/status` (not the plural
           `/api/v3/transactions/{id}` used everywhere else — both resolve, but
           this is the literal value returned). A synchronous validation error
           is `{ success: false, error: "<plain string>", timestamp }` — note
           `error` is a bare string here, not an `{code, message}` object.

        Identify the target line via `plan`, or via `Operator` + `network`
        directly. Idempotency is supported via an `Idempotency-Key` header
        (handled inline, not the standard interceptor) — a replay returns the
        same shape with `message: "Port-in already initiated (idempotent replay)"`.
      security: []
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [portInNumber, currentCarrierName, currentAccountNumber, currentAccountPassword, currentBillingAddress, employeeEmail, employeeName]
              properties:
                plan: { type: string, description: "Plan/product name; operator+network derived from it. Required when Operator/network are absent." }
                Operator: { type: string, description: Required when plan is absent. }
                network: { type: string, description: Required when plan is absent. }
                portInNumber: { type: string, description: "10-15 digits, optional leading +, cannot be all zeros.", example: "+15555551234" }
                currentCarrierName: { type: string }
                currentAccountNumber: { type: string }
                currentAccountPassword: { type: string, description: "Losing carrier's account PIN (FCC LNP) — treat like a credential, never log it." }
                currentBillingAddress:
                  type: object
                  required: [street1, city, state, zip]
                  properties:
                    street1: { type: string }
                    street2: { type: string }
                    city: { type: string }
                    state: { type: string, description: "US state, 2-letter or full name." }
                    zip: { type: string }
                iccid: { type: string, description: "19-20 digits.", example: "8901260853182965429" }
                targetPortinClassification: { type: string, enum: [mvno, iot] }
                subscriberName: { type: string, description: "Defaults to employeeName if omitted." }
                employeeEmail: { type: string, format: email, description: "Auto-creates the user on this account if no match exists." }
                employeeName: { type: string, description: Used when auto-creating the user. }
                metaData: { type: object, description: Free-form partner metadata for correlation. }
      responses:
        '200':
          description: Port-in initiated (note the flat, non-enveloped shape).
          content:
            application/json:
              example: { success: true, transactionId: "v3_1715567890123_abc123def456", statusEndpoint: "/api/v3/transaction/v3_1715567890123_abc123def456/status", message: "Port-in initiated", timestamp: "2026-07-31T09:00:00.000Z" }
        '400':
          description: Plan not found, or plan has no operator/network configured. `error` is a plain string, not an object.
          content:
            application/json:
              example: { success: false, error: "Plan 'AT0001' not found", timestamp: "2026-07-31T09:00:00.000Z" }
        '500':
          description: Transaction could not be initiated.
          content:
            application/json:
              example: { success: false, error: "Failed to initiate transaction" }

  # ─────────────────────────────── Messaging ──────────────────────────────────
  /api/v1.1/sms:
    post:
      tags: [Messaging]
      summary: Send an SMS
      operationId: sendSms
      description: |
        Send a Mobile-Originated (MO) SMS from a number provisioned on your
        account. Requires the account's `Admin` role tier. **MO SMS is only
        implemented for some operators** (SpenzaJ today) — everyone else gets
        `501`. This is a synchronous endpoint that returns `201` directly
        (NestJS's default for `@Post()`, since the handler has no `@HttpCode`
        override); it does not follow the async `202` + transaction-polling
        pattern used elsewhere. `fromNumber`, `toNumber`, and `text` are all
        shown as `required` below to reflect this endpoint's actual runtime
        behavior — enforcement happens in controller logic, not in the
        underlying DTO. `toNumber` is only validated for E.164 format; it is
        not checked against any ownership/allow-list.
      security: [ { bearerAuth: [] } ]
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fromNumber, toNumber, text]
              properties:
                fromNumber: { type: string, description: "A provisioned number in your inventory, E.164.", example: "+12792044584" }
                toNumber: { type: string, description: E.164., example: "+15555551234" }
                text: { type: string, example: "Hello from Spenza!" }
      responses:
        '201':
          description: Sent.
          content:
            application/json:
              example: { success: true, data: { message: "Outbound SMS sent successfully" } }
        '400':
          description: Missing fields, or a number not in E.164 format.
          content:
            application/json:
              examples:
                missing: { value: { success: false, error: { code: MISSING_FIELDS, message: Please provide the sender number, recipient number, and message text. } } }
                format: { value: { success: false, error: { code: VALIDATION_ERROR, message: "fromNumber must be a phone number in E.164 format, for example +15555550100." } } }
        '403':
          description: Your token's account role isn't `Admin`.
          content:
            application/json:
              example: { success: false, error: { code: FORBIDDEN, message: Sending SMS requires the account's Admin role. } }
        '404':
          description: fromNumber isn't a provisioned number on your account.
          content:
            application/json:
              example: { success: false, error: { code: SIM_NOT_FOUND, message: That phone number isn't part of your provisioned numbers. } }
        '409':
          description: The `Idempotency-Key` was reused with a different request, or a replay was attempted while the original request was still in flight.
          content:
            application/json:
              examples:
                idempotencyKeyReused: { value: { success: false, error: { code: IDEMPOTENCY_KEY_REUSED, message: This request was already submitted with different details — please use a new idempotency key. } } }
                conflict: { value: { success: false, error: { code: CONFLICT, message: A request with this idempotency key is already in progress. } } }
        '429': { $ref: '#/components/responses/RateLimited' }
        '501':
          description: This carrier doesn't support outbound SMS.
          content:
            application/json:
              example: { success: false, error: { code: NOT_IMPLEMENTED, message: Sending text messages isn't supported for this carrier yet. } }
        '500':
          description: The operator service couldn't be resolved, or the downstream send failed.
          content:
            application/json:
              example: { success: false, error: { code: INTERNAL_ERROR, message: The message could not be sent. Please try again later. } }

  # ─────────────────────────────── Webhooks ───────────────────────────────────
  /api/v1.1/webhooks:
    post:
      tags: [Webhooks]
      summary: Register or update a webhook
      operationId: registerWebhook
      description: |
        Register a webhook for operator SMS/voice events. At most **one
        registration per (account, operator, network)** — `eventType` is not
        part of that key, so registering a second event type for the same
        operator+network replaces the first. Registering again for an existing
        combination without `update: true` is a `409`.

        `eventType: voice` (or `both`) registers successfully as long as the
        operator/network combination itself is valid — there is no
        registration-time check that voice is actually enabled, unlike `sms`.
        Voice events only fire for a line whose active plan includes voice
        support; a line on a plan with no voice allowance won't produce voice
        events even with a registered webhook.

        **`signingSecret` is returned only from this creation path** (format
        `whsec_<64 hex chars>`) — no read endpoint ever returns it again. Store
        it immediately; losing it means re-registering. Use it to verify the
        `X-Spenza-Signature` header on every delivery (see the Webhooks guide).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [operator, network, eventType, authentication, webhookUrls]
              properties:
                operator: { type: string, example: SpenzaJ }
                network: { type: string, example: "T-Mobile" }
                eventType: { type: string, enum: [sms, voice, both] }
                authentication:
                  type: object
                  required: [type]
                  description: Credential your endpoint requires — Spenza presents it when calling you. This is not a payload-signing scheme (see `signingSecret`/`X-Spenza-Signature` for that).
                  properties:
                    type: { type: string, enum: [basic, bearer, api_key, none] }
                    username: { type: string, description: Required when type=basic. }
                    password: { type: string, description: Required when type=basic. }
                    token: { type: string, description: Required when type=bearer. }
                    apiKey: { type: string, description: Required when type=api_key. }
                    apiKeyHeader: { type: string, default: X-API-Key }
                webhookUrls:
                  type: object
                  properties:
                    messageUrl: { type: string, format: uri, description: Incoming SMS. }
                    callbackMessageUrl: { type: string, format: uri, description: SMS delivery-status callbacks. }
                    voiceUrl: { type: string, format: uri, description: Incoming voice events. }
                    callbackVoiceUrl: { type: string, format: uri, description: "Voice/trunk callbacks; payload includes a trunkType field." }
                    voiceStreamUrl: { type: string, description: "WS/WSS/HTTP(S) URL for live voice media — Spenza opens an outbound WS, sends a JSON handshake, then binary 8kHz/16-bit/mono PCM frames." }
                retryPolicy:
                  type: object
                  properties:
                    maxAttempts: { type: integer, minimum: 1, maximum: 10, default: 3 }
                    backoffStrategy: { type: string, enum: [fixed, exponential, linear], default: exponential }
                    initialDelaySeconds: { type: integer, minimum: 1, maximum: 60, default: 5 }
                    maxDelaySeconds: { type: integer, minimum: 60, maximum: 3600, default: 300 }
                status: { type: string, enum: [active, inactive], default: active }
                description: { type: string }
                update: { type: boolean, default: false, description: "Set true to overwrite an existing registration for this operator+network. Coerced via class-transformer — sending the string \"false\" is truthy and also overwrites; send a real JSON boolean." }
      responses:
        '201':
          description: Registered (or updated, if `update:true`).
          content:
            application/json:
              example:
                success: true
                data:
                  id: wh_69146d70ed68
                  operator: SpenzaJ
                  network: "T-Mobile"
                  eventType: sms
                  status: active
                  authentication: { type: bearer, token: "***ENCRYPTED***" }
                  webhookUrls: { messageUrl: "https://partner.example.com/hooks/sms", callbackMessageUrl: null, voiceUrl: null, callbackVoiceUrl: null, voiceStreamUrl: null }
                  retryPolicy: { maxAttempts: 5, backoffStrategy: exponential, initialDelaySeconds: 5, maxDelaySeconds: 300 }
                  description: null
                  createdAt: "2026-07-10T10:00:00.000Z"
                  updatedAt: "2026-07-10T10:00:00.000Z"
                  signingSecret: "whsec_9b1f8c2e7a4d6053b8e1f2a9c4d7e6053b8e1f2a9c4d7e6053b8e1f2a9c4d70"
        '400':
          description: Missing fields, or an unsupported operator/network/event-type combination.
          content:
            application/json:
              examples:
                missingFields: { value: { success: false, error: { code: MISSING_FIELDS, message: "Please fill in all the required fields to register this webhook.", details: { missingFields: ["webhookUrls"] } } } }
                badEventTypeOrAuth: { value: { success: false, error: { code: VALIDATION_ERROR, message: "Please check the event type and authentication settings you provided.", details: { field: eventType, allowed: [sms, voice, both] } } } }
                unknownOperatorNetwork: { value: { success: false, error: { code: VALIDATION_ERROR, message: "That operator and network combination does not exist." } } }
                notConfiguredForWebhooks: { value: { success: false, error: { code: VALIDATION_ERROR, message: "That operator and network combination is not configured for webhooks." } } }
                smsNotSupported: { value: { success: false, error: { code: VALIDATION_ERROR, message: "That operator and network combination does not currently support SMS webhooks." } } }
        '409':
          description: A webhook is already registered for this carrier without `update:true`.
          content:
            application/json:
              example: { success: false, error: { code: CONFLICT, message: "A webhook is already registered for this carrier and event type — set the update flag to change it." } }
    get:
      tags: [Webhooks]
      summary: List webhook registrations
      operationId: listWebhooks
      description: List webhook registrations, paginated in memory.
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PageSize'
        - { name: operator, in: query, schema: { type: string } }
        - { name: network, in: query, schema: { type: string } }
        - { name: eventType, in: query, schema: { type: string, enum: [sms, voice, both] } }
        - { name: status, in: query, schema: { type: string, enum: [active, inactive, suspended] } }
      responses:
        '200':
          description: Registrations (never includes `signingSecret`).
          content:
            application/json:
              example:
                success: true
                data:
                  - id: wh_69146d70ed68
                    operator: SpenzaJ
                    network: "T-Mobile"
                    eventType: sms
                    status: active
                    authentication: { type: bearer, token: "***ENCRYPTED***" }
                    webhookUrls: { messageUrl: "https://partner.example.com/hooks/sms" }
                    retryPolicy: { maxAttempts: 5, backoffStrategy: exponential, initialDelaySeconds: 5, maxDelaySeconds: 300 }
                    createdAt: "2026-07-10T10:00:00.000Z"
                    updatedAt: "2026-07-10T10:00:00.000Z"
                meta: { page: 1, pageSize: 25, total: 1, totalPages: 1 }

        '400':
          description: A filter value failed validation.
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["eventType must be one of the following values: sms, voice, both"] } } }
  /api/v1.1/webhooks/{id}:
    parameters: [ { name: id, in: path, required: true, schema: { type: string, example: wh_69146d70ed68 } } ]
    get:
      tags: [Webhooks]
      summary: Get a webhook registration
      operationId: getWebhook
      responses:
        '200':
          description: The registration (no `signingSecret`).
          content:
            application/json:
              example: { success: true, data: { id: wh_69146d70ed68, operator: SpenzaJ, network: "T-Mobile", eventType: sms, status: active, authentication: { type: bearer, token: "***ENCRYPTED***" }, webhookUrls: { messageUrl: "https://partner.example.com/hooks/sms" }, retryPolicy: { maxAttempts: 5, backoffStrategy: exponential, initialDelaySeconds: 5, maxDelaySeconds: 300 }, createdAt: "2026-07-10T10:00:00.000Z", updatedAt: "2026-07-10T10:00:00.000Z" } }
        '404':
          description: No registration with that id.
          content:
            application/json:
              example: { success: false, error: { code: NOT_FOUND, message: We couldn't find that webhook registration. } }
    put:
      tags: [Webhooks]
      summary: Update a webhook registration
      operationId: updateWebhook
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                authentication: { type: object }
                webhookUrls: { type: object }
                retryPolicy: { type: object }
                status: { type: string, enum: [active, inactive, suspended] }
                description: { type: string }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              example: { success: true, data: { id: wh_69146d70ed68, operator: SpenzaJ, network: "T-Mobile", eventType: sms, status: inactive, authentication: { type: bearer, token: "***ENCRYPTED***" }, webhookUrls: { messageUrl: "https://partner.example.com/hooks/sms" }, retryPolicy: { maxAttempts: 5, backoffStrategy: exponential, initialDelaySeconds: 5, maxDelaySeconds: 300 }, createdAt: "2026-07-10T10:00:00.000Z", updatedAt: "2026-07-15T10:00:00.000Z" } }
        '400':
          description: Bad `status`/`authentication.type` value, or a recognized `authentication.type` missing its required credential field(s).
          content:
            application/json:
              examples:
                badStatus: { value: { success: false, error: { code: VALIDATION_ERROR, message: "One of the fields you entered isn't valid — please check and try again.", details: { field: status, allowed: [active, inactive, suspended] } } } }
                badAuthType: { value: { success: false, error: { code: VALIDATION_ERROR, message: "One of the fields you entered isn't valid — please check and try again.", details: { field: "authentication.type", allowed: [basic, bearer, api_key, none] } } } }
                missingCredential: { value: { success: false, error: { code: MISSING_FIELDS, message: "Please fill in all the required fields to register this webhook.", details: { missingFields: ["authentication.token"] } } } }
        '404':
          description: No registration with that id.
          content:
            application/json:
              example: { success: false, error: { code: NOT_FOUND, message: We couldn't find that webhook registration. } }
    delete:
      tags: [Webhooks]
      summary: Delete a webhook registration
      operationId: deleteWebhook
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              example: { success: true, data: { id: wh_69146d70ed68, deleted: true } }
        '404':
          description: No registration with that id.
          content:
            application/json:
              example: { success: false, error: { code: NOT_FOUND, message: We couldn't find that webhook registration. } }

  /api/v1.1/webhooks/{id}/test:
    post:
      tags: [Webhooks]
      summary: Send a test event
      operationId: testWebhook
      description: |
        Send a signed synthetic test event to the registered endpoint. One
        attempt, no retries. The attempt is recorded in
        `GET /api/v1.1/webhook-deliveries` but deliberately does **not** count
        toward the 10-consecutive-failure auto-suspend threshold. Target URL
        priority (fixed, not event-derived): `messageUrl` → `callbackMessageUrl`
        → `voiceUrl` → `callbackVoiceUrl` (`voiceStreamUrl` is never a test target).

        This route has its own stricter rate limit — **10 requests / 60s**,
        on top of (not instead of) the account-wide default — since it POSTs
        to an arbitrary partner-supplied URL.
      parameters: [ { name: id, in: path, required: true, schema: { type: string, example: wh_69146d70ed68 } } ]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                eventType: { type: string, default: webhook.test, description: "No enum — send whatever event name you want to rehearse." }
      responses:
        '200':
          description: Test attempted (regardless of whether it succeeded — see `delivered`).
          content:
            application/json:
              example: { success: true, data: { delivered: true, responseCode: 200, attemptedAt: "2026-07-31T09:00:00.000Z" } }
        '400':
          description: No target URL configured on this registration.
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: This webhook has no endpoint URL configured to send a test event to. } }
        '404':
          description: No webhook with that id.
          content:
            application/json:
              example: { success: false, error: { code: WEBHOOK_NOT_FOUND, message: We couldn't find that webhook. } }
        '429': { $ref: '#/components/responses/RateLimited' }
        '502':
          description: Your endpoint didn't respond successfully.
          content:
            application/json:
              example: { success: false, error: { code: DELIVERY_FAILED, message: "We sent the test event but your endpoint didn't respond successfully — check your handler and try again." } }

  /api/v1.1/webhook-deliveries:
    get:
      tags: [Webhooks]
      summary: List webhook deliveries
      operationId: listWebhookDeliveries
      description: List recent webhook delivery attempts, newest first. Never includes the delivered payload, response body, or raw partner-endpoint error text.
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PageSize'
        - { name: webhookId, in: query, description: "Filter to one registration. An unparseable id returns an empty page, not a 404.", schema: { type: string } }
        - { name: status, in: query, schema: { type: string, enum: [SUCCESS, FAILED, PENDING] } }
      responses:
        '200':
          description: A page of delivery attempts.
          content:
            application/json:
              example:
                success: true
                data:
                  - deliveryId: "64f1a2b3c4d5e6f7a8b9c0d1"
                    webhookId: wh_69146d70ed68
                    eventType: sim.status_changed
                    status: SUCCESS
                    responseCode: 200
                    attempt: 1
                    deliveredAt: "2026-07-31T08:00:00.000Z"
                meta: { page: 1, pageSize: 25, total: 5, totalPages: 1 }

        '400':
          description: "`status` must be a valid enum value."
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["status must be one of the following values: SUCCESS, FAILED, PENDING"] } } }
  /api/v1.1/webhook-deliveries/{id}/redeliver:
    post:
      tags: [Webhooks]
      summary: Redeliver a webhook event
      operationId: redeliverWebhookDelivery
      description: |
        Queue a redelivery of a past event to its original endpoint, using the
        stored URL and body of the original attempt (not a regenerated event).
        Runs on a queue consumer, never in the request cycle — poll the
        deliveries list for the outcome.
      parameters:
        - { name: id, in: path, required: true, description: "The delivery's id, not the webhook's.", schema: { type: string, example: "64f1a2b3c4d5e6f7a8b9c0d1" } }
        - { $ref: '#/components/parameters/IdempotencyKey' }
      responses:
        '202':
          description: Redelivery queued.
          content:
            application/json:
              example: { success: true, data: { deliveryId: "64f1a2b3c4d5e6f7a8b9c0d2", status: PENDING } }
        '404':
          description: No delivery with that id.
          content:
            application/json:
              example: { success: false, error: { code: DELIVERY_NOT_FOUND, message: We couldn't find that delivery record. } }
        '409':
          description: This event is already being (re)delivered, or the `Idempotency-Key` was reused with a different request.
          content:
            application/json:
              examples:
                alreadyInProgress: { value: { success: false, error: { code: DELIVERY_ALREADY_IN_PROGRESS, message: "This event is already being redelivered — please wait for it to finish." } } }
                idempotencyReused: { value: { success: false, error: { code: IDEMPOTENCY_KEY_REUSED, message: This request was already submitted with different details — please use a new idempotency key. } } }
        '500':
          description: Failed to queue the redelivery.
          content:
            application/json:
              example: { success: false, error: { code: INTERNAL_ERROR, message: "We couldn't start that redelivery — please try again shortly." } }

  # ────────────────────────────── Notifications ───────────────────────────────
  /api/v1.1/notifications:
    get:
      tags: [Notifications]
      summary: List notifications
      operationId: listNotifications
      description: List notifications for your account, newest first. `meta` carries an extra `unreadCount` field alongside the usual pagination fields — `unreadCount` is always the account-wide unread total; it is not filtered by the `status` query parameter and does not reflect the returned page's contents.
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PageSize'
        - { name: status, in: query, schema: { type: string, enum: [unread, read, all], default: all } }
      responses:
        '200':
          description: A page of notifications.
          content:
            application/json:
              example:
                success: true
                data:
                  - id: "64f1a2b3c4d5e6f7a8b9c0d1"
                    category: billing
                    title: "Low balance"
                    body: "Your prepaid balance is below $10."
                    ctaRoute: /billing
                    ctaParam: null
                    iccid: null
                    status: unread
                    createdAt: "2026-07-31T08:00:00.000Z"
                meta: { page: 1, pageSize: 25, total: 4, totalPages: 1, unreadCount: 2 }

        '400':
          description: "`status` must be a valid enum value."
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["status must be one of the following values: unread, read, all"] } } }

  /api/v1.1/notification-preferences:
    get:
      tags: [Notifications]
      summary: Get notification preferences
      operationId: getNotificationPreferences
      description: |
        Always returns the full preference matrix — one entry per event type,
        even ones you've never touched. Unset keys resolve to that key's
        default: `sendOnboardingNotification` defaults `true` (opt-out); every
        other key defaults `false` (opt-in).
      responses:
        '200':
          description: The full preference matrix.
          content:
            application/json:
              example:
                success: true
                data:
                  preferences:
                    - { eventType: simAssignment, enabled: false }
                    - { eventType: planPurchase, enabled: true }
                    - { eventType: activatePlan, enabled: false }
                    - { eventType: lowBalance, enabled: false }
                    - { eventType: planExpiry, enabled: false }
                    - { eventType: purchasePlanError, enabled: false }
                    - { eventType: qrCodeForEsimActivation, enabled: false }
                    - { eventType: sendQRCodeEmail, enabled: false }
                    - { eventType: sendCancellationEmail, enabled: false }
                    - { eventType: sendOnboardingNotification, enabled: true }
        '500':
          description: Failed to look up your account's preferences.
          content:
            application/json:
              example: { success: false, error: { code: INTERNAL_ERROR, message: Something went wrong on our end. Please contact Spenza support if this continues. } }
    put:
      tags: [Notifications]
      summary: Update a notification preference
      operationId: updateNotificationPreference
      description: Enable or disable exactly one preference key per call — despite the plural resource name, this is a single-key update. Returns the full matrix so you can confirm without a follow-up GET.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [eventType, enabled]
              properties:
                eventType: { type: string, enum: [simAssignment, planPurchase, activatePlan, lowBalance, planExpiry, purchasePlanError, qrCodeForEsimActivation, sendQRCodeEmail, sendCancellationEmail, sendOnboardingNotification] }
                enabled: { type: boolean }
      responses:
        '200':
          description: The full preference matrix, post-update.
          content:
            application/json:
              example:
                success: true
                data:
                  preferences:
                    - { eventType: simAssignment, enabled: false }
                    - { eventType: planPurchase, enabled: false }
                    - { eventType: activatePlan, enabled: false }
                    - { eventType: lowBalance, enabled: false }
                    - { eventType: planExpiry, enabled: false }
                    - { eventType: purchasePlanError, enabled: false }
                    - { eventType: qrCodeForEsimActivation, enabled: false }
                    - { eventType: sendQRCodeEmail, enabled: false }
                    - { eventType: sendCancellationEmail, enabled: false }
                    - { eventType: sendOnboardingNotification, enabled: true }
        '400':
          description: Missing/invalid `eventType` or `enabled`.
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["enabled must be a boolean value"] } } }
        '500':
          description: Failed to re-fetch your account's preferences after the update.
          content:
            application/json:
              example: { success: false, error: { code: INTERNAL_ERROR, message: Something went wrong on our end. Please contact Spenza support if this continues. } }

  # ────────────────────────────── Device Groups ───────────────────────────────
  /api/v1.1/device-groups:
    get:
      tags: [Device Groups]
      summary: List device-groups
      operationId: listDeviceGroups
      parameters: [ { $ref: '#/components/parameters/Page' }, { $ref: '#/components/parameters/PageSize' } ]
      responses:
        '200':
          description: A page of device-groups.
          content:
            application/json:
              example: { success: true, data: [ { name: "Field Techs", spendLimit: 500, dataQuota: 50, currency: USD, dataUnit: GB } ], meta: { page: 1, pageSize: 25, total: 1, totalPages: 1 } }
        '400':
          description: "`page`/`pageSize` out of range."
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["pageSize must not be greater than 100"] } } }
    post:
      tags: [Device Groups]
      summary: Create a device-group
      operationId: createDeviceGroup
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 100, example: "Field Techs" }
                spendLimit: { type: number, minimum: 0, description: "Monthly spend limit, smallest currency unit." }
                dataQuota: { type: number, minimum: 0 }
                currency: { type: string, maxLength: 10, example: USD }
                dataUnit: { type: string, enum: [KB, MB, GB] }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              example: { success: true, data: { name: "Field Techs", spendLimit: 500, dataQuota: 50, currency: USD, dataUnit: GB } }
        '400':
          description: Missing `name`, or an invalid field value.
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["name should not be empty"] } } }
        '409':
          description: A device-group with this name already exists.
          content:
            application/json:
              example: { success: false, error: { code: DEVICE_GROUP_ALREADY_EXISTS, message: A device-group with this name already exists on your account. } }
        '429':
          description: "Lock contention: another request is already creating a device-group with this exact name on this account — not the account-wide rate limit. Retry shortly."
          content:
            application/json:
              example: { success: false, error: { code: RATE_LIMITED, message: "Too many concurrent requests for this device-group name — please retry in a moment." } }

  /api/v1.1/device-groups/{name}:
    parameters: [ { name: name, in: path, required: true, description: "The group's name — exact match, case-sensitive. Not renamable once created.", schema: { type: string, example: "Field Techs" } } ]
    get:
      tags: [Device Groups]
      summary: Get a device-group
      operationId: getDeviceGroup
      responses:
        '200':
          description: The device-group.
          content:
            application/json:
              example: { success: true, data: { name: "Field Techs", spendLimit: 500, dataQuota: 50, currency: USD, dataUnit: GB } }
        '404':
          description: No device-group with that name (also covers another account's, or a same-named sim-group).
          content:
            application/json:
              example: { success: false, error: { code: DEVICE_GROUP_NOT_FOUND, message: We couldn't find a device-group with that name on your account. } }
    put:
      tags: [Device Groups]
      summary: Update a device-group
      operationId: updateDeviceGroup
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                spendLimit: { type: number, minimum: 0 }
                dataQuota: { type: number, minimum: 0 }
                currency: { type: string, maxLength: 10 }
                dataUnit: { type: string, enum: [KB, MB, GB] }
      responses:
        '200':
          description: "Updated. Note: the response is a client-side merge of the pre-update group snapshot with the raw fields you sent, not a fresh read of the row after saving — omitted fields echo the prior stored value, and fields you sent are echoed back as-submitted rather than re-read from the database."
          content:
            application/json:
              example: { success: true, data: { name: "Field Techs", spendLimit: 750, dataQuota: 50, currency: USD, dataUnit: GB } }
        '400':
          description: An invalid field value.
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["spendLimit must not be less than 0"] } } }
        '404':
          description: No device-group with that name.
          content:
            application/json:
              example: { success: false, error: { code: DEVICE_GROUP_NOT_FOUND, message: We couldn't find a device-group with that name on your account. } }
    delete:
      tags: [Device Groups]
      summary: Delete a device-group
      operationId: deleteDeviceGroup
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              example: { success: true, data: { name: "Field Techs", status: DELETED } }
        '404':
          description: No device-group with that name.
          content:
            application/json:
              example: { success: false, error: { code: DEVICE_GROUP_NOT_FOUND, message: We couldn't find a device-group with that name on your account. } }
        '409':
          description: "Group is the account default (no account currently has a default device-group provisioned, so this case is not reachable today), or still has devices in it."
          content:
            application/json:
              examples:
                default: { value: { success: false, error: { code: CANNOT_DELETE_DEFAULT_GROUP, message: Your account's default device-group can't be deleted. } } }
                notEmpty: { value: { success: false, error: { code: DEVICE_GROUP_NOT_EMPTY, message: This device-group still has devices in it — move them out before deleting it. } } }

  /api/v1.1/device-groups/{name}/devices:
    post:
      tags: [Device Groups]
      summary: Move a device into this group
      operationId: addDeviceToGroup
      parameters:
        - { name: name, in: path, required: true, schema: { type: string, example: "Field Techs" } }
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [meid]
              properties:
                meid: { type: string, description: MEID of the device to move into this group., example: "123452189012345" }
      responses:
        '200':
          description: Device moved (moving a device already in another group reassigns it).
          content:
            application/json:
              example: { success: true, data: { meid: "123452189012345", movedTo: "Field Techs" } }
        '400':
          description: Missing `meid`.
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["meid should not be empty"] } } }
        '404':
          description: Device-group or device not found.
          content:
            application/json:
              examples:
                group: { value: { success: false, error: { code: DEVICE_GROUP_NOT_FOUND, message: We couldn't find a device-group with that name on your account. } } }
                device: { value: { success: false, error: { code: DEVICE_NOT_FOUND, message: We couldn't find a device with that MEID on your account. } } }

  /api/v1.1/device-groups/{name}/devices/{meid}:
    delete:
      tags: [Device Groups]
      summary: Remove a device from this group
      operationId: removeDeviceFromGroup
      description: No default device-group exists, so the device's group is simply cleared (`movedTo` is `null`).
      parameters:
        - { name: name, in: path, required: true, schema: { type: string, example: "Field Techs" } }
        - { name: meid, in: path, required: true, schema: { type: string, example: "123452189012345" } }
        - { $ref: '#/components/parameters/IdempotencyKey' }
      responses:
        '200':
          description: Device removed from the group.
          content:
            application/json:
              example: { success: true, data: { meid: "123452189012345", movedTo: null } }
        '404':
          description: Device-group not found, or device isn't in this group.
          content:
            application/json:
              examples:
                group: { value: { success: false, error: { code: DEVICE_GROUP_NOT_FOUND, message: We couldn't find a device-group with that name on your account. } } }
                notInGroup: { value: { success: false, error: { code: DEVICE_NOT_IN_GROUP, message: "That device isn't in this device-group." } } }

  # ─────────────────────────────── SIM Groups ─────────────────────────────────
  /api/v1.1/sim-groups:
    get:
      tags: [SIM Groups]
      summary: List sim-groups
      operationId: listSimGroups
      parameters: [ { $ref: '#/components/parameters/Page' }, { $ref: '#/components/parameters/PageSize' } ]
      responses:
        '200':
          description: A page of sim-groups.
          content:
            application/json:
              example: { success: true, data: [ { name: "Sales Team", spendLimit: 1000, dataQuota: 100, currency: USD, dataUnit: GB } ], meta: { page: 1, pageSize: 25, total: 2, totalPages: 1 } }
        '400':
          description: "`page`/`pageSize` out of range."
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["pageSize must not be greater than 100"] } } }
    post:
      tags: [SIM Groups]
      summary: Create a sim-group
      operationId: createSimGroup
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 100, example: "Sales Team" }
                spendLimit: { type: number, minimum: 0 }
                dataQuota: { type: number, minimum: 0 }
                currency: { type: string, maxLength: 10, example: USD }
                dataUnit: { type: string, enum: [KB, MB, GB] }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              example: { success: true, data: { name: "Sales Team", spendLimit: 1000, dataQuota: 100, currency: USD, dataUnit: GB } }
        '400':
          description: Missing `name`, or an invalid field value.
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["name should not be empty"] } } }
        '409':
          description: A sim-group with this name already exists.
          content:
            application/json:
              example: { success: false, error: { code: SIM_GROUP_ALREADY_EXISTS, message: A sim-group with this name already exists on your account. } }
        '429':
          description: "Lock contention: another request is already creating a sim-group with this exact name on this account — not the account-wide rate limit. Retry shortly."
          content:
            application/json:
              example: { success: false, error: { code: RATE_LIMITED, message: "Too many concurrent requests for this sim-group name — please retry in a moment." } }

  /api/v1.1/sim-groups/{name}:
    parameters: [ { name: name, in: path, required: true, description: "Exact match, case-sensitive. Not renamable.", schema: { type: string, example: "Sales Team" } } ]
    get:
      tags: [SIM Groups]
      summary: Get a sim-group
      operationId: getSimGroup
      responses:
        '200':
          description: The sim-group.
          content:
            application/json:
              example: { success: true, data: { name: "Sales Team", spendLimit: 1000, dataQuota: 100, currency: USD, dataUnit: GB } }
        '404':
          description: No sim-group with that name (also covers another account's, or a same-named device-group).
          content:
            application/json:
              example: { success: false, error: { code: SIM_GROUP_NOT_FOUND, message: We couldn't find a sim-group with that name on your account. } }
    put:
      tags: [SIM Groups]
      summary: Update a sim-group
      operationId: updateSimGroup
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                spendLimit: { type: number, minimum: 0 }
                dataQuota: { type: number, minimum: 0 }
                currency: { type: string, maxLength: 10 }
                dataUnit: { type: string, enum: [KB, MB, GB] }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              example: { success: true, data: { name: "Sales Team", spendLimit: 1500, dataQuota: 100, currency: USD, dataUnit: GB } }
        '400':
          description: An invalid field value.
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["dataUnit must be one of the following values: KB, MB, GB"] } } }
        '404':
          description: No sim-group with that name.
          content:
            application/json:
              example: { success: false, error: { code: SIM_GROUP_NOT_FOUND, message: We couldn't find a sim-group with that name on your account. } }
    delete:
      tags: [SIM Groups]
      summary: Delete a sim-group
      operationId: deleteSimGroup
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      responses:
        '200':
          description: Deleted (soft delete).
          content:
            application/json:
              example: { success: true, data: { name: "Sales Team", status: DELETED } }
        '404':
          description: No sim-group with that name.
          content:
            application/json:
              example: { success: false, error: { code: SIM_GROUP_NOT_FOUND, message: We couldn't find a sim-group with that name on your account. } }
        '409':
          description: Group is the account default, or still has SIMs in it.
          content:
            application/json:
              examples:
                default: { value: { success: false, error: { code: CANNOT_DELETE_DEFAULT_GROUP, message: Your account's default sim-group can't be deleted. } } }
                notEmpty: { value: { success: false, error: { code: SIM_GROUP_NOT_EMPTY, message: This sim-group still has SIMs in it — move them out before deleting it. } } }

  /api/v1.1/sim-groups/{name}/sims:
    post:
      tags: [SIM Groups]
      summary: Move a SIM into this group
      operationId: addSimToGroup
      parameters:
        - { name: name, in: path, required: true, schema: { type: string, example: "Sales Team" } }
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [iccid]
              properties:
                iccid: { type: string, description: ICCID of the SIM to move into this group., example: "8901260853182965429" }
      responses:
        '200':
          description: SIM moved.
          content:
            application/json:
              example: { success: true, data: { iccid: "8901260853182965429", movedTo: "Sales Team" } }
        '400':
          description: Missing `iccid`.
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["iccid should not be empty"] } } }
        '404':
          description: Sim-group or SIM not found.
          content:
            application/json:
              examples:
                group: { value: { success: false, error: { code: SIM_GROUP_NOT_FOUND, message: We couldn't find a sim-group with that name on your account. } } }
                sim: { value: { success: false, error: { code: SIM_NOT_FOUND, message: We couldn't find a SIM with that ICCID on your account. } } }

  /api/v1.1/sim-groups/{name}/sims/{iccid}:
    delete:
      tags: [SIM Groups]
      summary: Remove a SIM from this group
      operationId: removeSimFromGroup
      description: The SIM moves back to the account's default sim-group — `movedTo` is the literal string `"default"`, not that group's actual name.
      parameters:
        - { name: name, in: path, required: true, schema: { type: string, example: "Sales Team" } }
        - { $ref: '#/components/parameters/Iccid' }
        - { $ref: '#/components/parameters/IdempotencyKey' }
      responses:
        '200':
          description: SIM removed from the group.
          content:
            application/json:
              example: { success: true, data: { iccid: "8901260853182965429", movedTo: "default" } }
        '404':
          description: Sim-group not found, or SIM isn't in this group.
          content:
            application/json:
              examples:
                group: { value: { success: false, error: { code: SIM_GROUP_NOT_FOUND, message: We couldn't find a sim-group with that name on your account. } } }
                notInGroup: { value: { success: false, error: { code: SIM_NOT_IN_GROUP, message: "That SIM isn't in this sim-group." } } }

  # ─────────────────────────────── User Groups ────────────────────────────────
  /api/v1.1/user-groups:
    get:
      tags: [User Groups]
      summary: List user-groups
      operationId: listUserGroups
      description: A "user-group" is a Department under the hood — a separate collection from SIM/device groups, with its own independent name uniqueness.
      parameters: [ { $ref: '#/components/parameters/Page' }, { $ref: '#/components/parameters/PageSize' } ]
      responses:
        '200':
          description: A page of user-groups.
          content:
            application/json:
              example: { success: true, data: [ { name: Engineering, spendLimit: 2000, dataQuota: 200, currency: USD, dataUnit: GB } ], meta: { page: 1, pageSize: 25, total: 3, totalPages: 1 } }
        '400':
          description: "`page`/`pageSize` out of range."
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["pageSize must not be greater than 100"] } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [User Groups]
      summary: Create a user-group
      operationId: createUserGroup
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 100, example: Engineering }
                spendLimit: { type: number, minimum: 0 }
                dataQuota: { type: number, minimum: 0 }
                currency: { type: string, maxLength: 10, example: USD }
                dataUnit: { type: string, enum: [KB, MB, GB] }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              example: { success: true, data: { name: Engineering, spendLimit: 2000, dataQuota: 200, currency: USD, dataUnit: GB } }
        '400':
          description: Missing `name`, or an invalid field value.
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["name should not be empty"] } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409':
          description: A user-group with this name already exists, or the `Idempotency-Key` header was reused.
          content:
            application/json:
              examples:
                alreadyExists: { value: { success: false, error: { code: USER_GROUP_ALREADY_EXISTS, message: A user-group with this name already exists on your account. } } }
                idempotencyKeyReused: { value: { success: false, error: { code: IDEMPOTENCY_KEY_REUSED, message: This request was already submitted with different details — please use a new idempotency key. } } }
                idempotencyKeyInFlight: { value: { success: false, error: { code: CONFLICT, message: "This request is already being processed — please retry in a moment." } } }
        '429':
          description: "Lock contention: another request is already creating a user-group with this exact name on this account (not the account-wide rate limit), or the account-wide rate limit was exceeded."
          content:
            application/json:
              examples:
                lockContention: { value: { success: false, error: { code: RATE_LIMITED, message: "Too many concurrent requests for this user-group name — please retry in a moment." } } }
                rateLimited: { value: { success: false, error: { code: RATE_LIMITED, message: "ThrottlerException: Too Many Requests" } } }

  /api/v1.1/user-groups/{name}:
    parameters: [ { name: name, in: path, required: true, description: "Exact match, case-sensitive. Not renamable.", schema: { type: string, example: Engineering } } ]
    get:
      tags: [User Groups]
      summary: Get a user-group
      operationId: getUserGroup
      responses:
        '200':
          description: The user-group.
          content:
            application/json:
              example: { success: true, data: { name: Engineering, spendLimit: 2000, dataQuota: 200, currency: USD, dataUnit: GB } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: No user-group with that name.
          content:
            application/json:
              example: { success: false, error: { code: USER_GROUP_NOT_FOUND, message: We couldn't find a user-group with that name on your account. } }
        '429': { $ref: '#/components/responses/RateLimited' }
    put:
      tags: [User Groups]
      summary: Update a user-group
      operationId: updateUserGroup
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                spendLimit: { type: number, minimum: 0 }
                dataQuota: { type: number, minimum: 0 }
                currency: { type: string, maxLength: 10 }
                dataUnit: { type: string, enum: [KB, MB, GB] }
      responses:
        '200':
          description: "Updated. Note: the response is a client-side merge of the pre-update group snapshot with the raw fields you sent, not a fresh read of the row after saving — omitted fields echo the prior stored value, and fields you sent are echoed back as-submitted rather than re-read from the database."
          content:
            application/json:
              example: { success: true, data: { name: Engineering, spendLimit: 2500, dataQuota: 200, currency: USD, dataUnit: GB } }
        '400':
          description: An invalid field value.
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["spendLimit must not be less than 0"] } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: No user-group with that name.
          content:
            application/json:
              example: { success: false, error: { code: USER_GROUP_NOT_FOUND, message: We couldn't find a user-group with that name on your account. } }
        '409':
          description: The `Idempotency-Key` header was reused with a different request body/route, or reused while the original request was still in flight.
          content:
            application/json:
              examples:
                idempotencyKeyReused: { value: { success: false, error: { code: IDEMPOTENCY_KEY_REUSED, message: This request was already submitted with different details — please use a new idempotency key. } } }
                idempotencyKeyInFlight: { value: { success: false, error: { code: CONFLICT, message: "This request is already being processed — please retry in a moment." } } }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [User Groups]
      summary: Delete a user-group
      operationId: deleteUserGroup
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      responses:
        '200':
          description: Deleted (soft delete).
          content:
            application/json:
              example: { success: true, data: { name: Engineering, status: DELETED } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: No user-group with that name.
          content:
            application/json:
              example: { success: false, error: { code: USER_GROUP_NOT_FOUND, message: We couldn't find a user-group with that name on your account. } }
        '409':
          description: Group is the account default, or still has users in it, or the `Idempotency-Key` header was reused.
          content:
            application/json:
              examples:
                default: { value: { success: false, error: { code: CANNOT_DELETE_DEFAULT_GROUP, message: Your account's default user-group can't be deleted. } } }
                notEmpty: { value: { success: false, error: { code: USER_GROUP_NOT_EMPTY, message: This user-group still has users in it — move them out before deleting it. } } }
                idempotencyKeyReused: { value: { success: false, error: { code: IDEMPOTENCY_KEY_REUSED, message: This request was already submitted with different details — please use a new idempotency key. } } }
                idempotencyKeyInFlight: { value: { success: false, error: { code: CONFLICT, message: "This request is already being processed — please retry in a moment." } } }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1.1/user-groups/{name}/members:
    post:
      tags: [User Groups]
      summary: Move a user into this group
      operationId: addUserGroupMember
      parameters:
        - { name: name, in: path, required: true, schema: { type: string, example: Engineering } }
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email, example: jane@example.com }
      responses:
        '200':
          description: User moved.
          content:
            application/json:
              example: { success: true, data: { email: jane@example.com, movedTo: Engineering } }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: User-group or user not found.
          content:
            application/json:
              examples:
                group: { value: { success: false, error: { code: USER_GROUP_NOT_FOUND, message: We couldn't find a user-group with that name on your account. } } }
                user: { value: { success: false, error: { code: USER_NOT_FOUND, message: We couldn't find a user with that email on your account. } } }
        '409':
          description: The `Idempotency-Key` header was reused with a different request body/route, or reused while the original request was still in flight.
          content:
            application/json:
              examples:
                idempotencyKeyReused: { value: { success: false, error: { code: IDEMPOTENCY_KEY_REUSED, message: This request was already submitted with different details — please use a new idempotency key. } } }
                idempotencyKeyInFlight: { value: { success: false, error: { code: CONFLICT, message: "This request is already being processed — please retry in a moment." } } }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1.1/user-groups/{name}/members/{email}:
    delete:
      tags: [User Groups]
      summary: Remove a user from this group
      operationId: removeUserGroupMember
      description: The user's department is cleared (`movedTo` is `null` — unlike sim-groups, there's no default user-group fallback).
      parameters:
        - { name: name, in: path, required: true, schema: { type: string, example: Engineering } }
        - { name: email, in: path, required: true, description: URL-encode the @., schema: { type: string, format: email, example: jane@example.com } }
        - { $ref: '#/components/parameters/IdempotencyKey' }
      responses:
        '200':
          description: User removed from the group.
          content:
            application/json:
              example: { success: true, data: { email: jane@example.com, movedTo: null } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: User-group not found, or a malformed/unrelated email (returns the same code either way).
          content:
            application/json:
              examples:
                group: { value: { success: false, error: { code: USER_GROUP_NOT_FOUND, message: We couldn't find a user-group with that name on your account. } } }
                notInGroup: { value: { success: false, error: { code: USER_NOT_IN_GROUP, message: "That user isn't in this user-group." } } }
        '409':
          description: The `Idempotency-Key` header was reused with a different request body/route, or reused while the original request was still in flight.
          content:
            application/json:
              examples:
                idempotencyKeyReused: { value: { success: false, error: { code: IDEMPOTENCY_KEY_REUSED, message: This request was already submitted with different details — please use a new idempotency key. } } }
                idempotencyKeyInFlight: { value: { success: false, error: { code: CONFLICT, message: "This request is already being processed — please retry in a moment." } } }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ──────────────────────────────── Devices ───────────────────────────────────
  /api/v1.1/team-devices:
    get:
      tags: [Devices]
      summary: List devices in your catalog
      operationId: listTeamDevices
      description: List the phones/tablets you can hand out to your team.
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PageSize'
        - { name: search, in: query, description: "Case-insensitive match on MEID, model, vendor, or model key.", schema: { type: string, maxLength: 100 } }
      responses:
        '200':
          description: A page of devices.
          content:
            application/json:
              example:
                success: true
                data:
                  - imei: "356938035643809"
                    deviceName: "iPhone 14"
                    vendor: Apple
                    sku: null
                    assignedTo: { name: Jane Doe, email: jane@example.com }
                meta: { page: 1, pageSize: 25, total: 50, totalPages: 2 }

        '400':
          description: "`search` too long."
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["search must be shorter than or equal to 100 characters"] } } }
  /api/v1.1/devices/{imei}:
    get:
      tags: [Devices]
      summary: Get a device
      operationId: getDevice
      description: Get a device's details, including who it's currently assigned to.
      parameters: [ { name: imei, in: path, required: true, description: Device IMEI (MEID)., schema: { type: string, example: "356938035643809" } } ]
      responses:
        '200':
          description: The device.
          content:
            application/json:
              example: { success: true, data: { imei: "356938035643809", deviceName: "iPhone 14", vendor: Apple, sku: null, assignedTo: { name: Jane Doe, email: jane@example.com } } }
        '404':
          description: No device with that IMEI on your account.
          content:
            application/json:
              example: { success: false, error: { code: DEVICE_NOT_FOUND, message: We couldn't find a device with that IMEI on your account. } }

  /api/v1.1/devices/{imei}/assign:
    post:
      tags: [Devices]
      summary: Assign a device to a user
      operationId: assignDevice
      description: The "user" here is a Employee — the same resource as `/api/v1.1/users` — not a Team Member/admin.
      parameters:
        - { name: imei, in: path, required: true, description: Device IMEI (MEID)., schema: { type: string, example: "356938035643809" } }
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email, example: jane@example.com }
      responses:
        '200':
          description: Assigned.
          content:
            application/json:
              example: { success: true, data: { imei: "356938035643809", assignedTo: { name: Jane Doe, email: jane@example.com } } }
        '400':
          description: Email missing or malformed.
          content:
            application/json:
              examples:
                missing: { value: { success: false, error: { code: MISSING_FIELDS, message: Please provide an email address to assign this device. } } }
                malformed: { value: { success: false, error: { code: VALIDATION_ERROR, message: "That doesn't look like a valid email address — please double-check it." } } }
        '404':
          description: Device or user not found.
          content:
            application/json:
              examples:
                device: { value: { success: false, error: { code: DEVICE_NOT_FOUND, message: We couldn't find a device with that IMEI on your account. } } }
                user: { value: { success: false, error: { code: USER_NOT_FOUND, message: We couldn't find a user with that email on your account. } } }
        '409':
          description: Device is already assigned to someone, or the `Idempotency-Key` was reused with a different request.
          content:
            application/json:
              examples:
                alreadyAssigned: { value: { success: false, error: { code: DEVICE_ALREADY_ASSIGNED, message: "This device is already assigned to someone — unassign it first." } } }
                idempotencyKeyReused: { value: { success: false, error: { code: IDEMPOTENCY_KEY_REUSED, message: This request was already submitted with different details — please use a new idempotency key. } } }

  /api/v1.1/devices/{imei}/unassign:
    post:
      tags: [Devices]
      summary: Unassign a device
      operationId: unassignDevice
      parameters: [ { name: imei, in: path, required: true, description: Device IMEI (MEID)., schema: { type: string, example: "356938035643809" } } ]
      responses:
        '200':
          description: Unassigned — device becomes available again.
          content:
            application/json:
              example: { success: true, data: { imei: "356938035643809", assignedTo: null } }
        '404':
          description: No device with that IMEI on your account.
          content:
            application/json:
              example: { success: false, error: { code: DEVICE_NOT_FOUND, message: We couldn't find a device with that IMEI on your account. } }
        '409':
          description: Device isn't currently assigned to anyone.
          content:
            application/json:
              example: { success: false, error: { code: DEVICE_NOT_ASSIGNED, message: This device isn't currently assigned to anyone. } }

  # ─────────────────────────────── Team Members ───────────────────────────────
  /api/v1.1/team-members:
    get:
      tags: [Team Members]
      summary: List team members
      operationId: listTeamMembers
      description: Team members are account **admins** (Auth0-backed dashboard/API login), a different resource from `/api/v1.1/users` (end users who hold SIMs/plans).
      parameters: [ { $ref: '#/components/parameters/Page' }, { $ref: '#/components/parameters/PageSize' } ]
      responses:
        '200':
          description: A page of team members. `departments` appears only on `Standard Admin` rows.
          content:
            application/json:
              example:
                success: true
                data:
                  - name: Jane Doe
                    email: jane@example.com
                    role: Admin
                    status: active
                meta: { page: 1, pageSize: 25, total: 3, totalPages: 1 }
        '400':
          description: "`page`/`pageSize` out of range."
          content:
            application/json:
              example: { success: false, error: { code: VALIDATION_ERROR, message: 'One or more fields failed validation.', details: { validationErrors: ["pageSize must not be greater than 100"] } } }
    post:
      tags: [Team Members]
      summary: Invite a teammate as an admin
      operationId: createTeamMember
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, email, role]
              properties:
                name: { type: string, minLength: 1, maxLength: 100, example: Jane Doe }
                email: { type: string, format: email, example: jane@example.com }
                role: { type: string, enum: [Admin, "RW Admin", "RO Admin", "Standard Admin"] }
                departments: { type: array, items: { type: string }, description: "Department ids this admin can manage. Required (non-empty) when role is \"Standard Admin\"; ignored otherwise." }
      responses:
        '201':
          description: Invited (Auth0 invite email sent). `status` is always `invited` on creation.
          content:
            application/json:
              example: { success: true, data: { name: Jane Doe, email: jane@example.com, role: Admin, status: invited } }
        '400':
          description: Missing fields, a Standard Admin invited with no departments, or an account/portfolio integrity problem on your account.
          content:
            application/json:
              examples:
                missing: { value: { success: false, error: { code: MISSING_FIELDS, message: "Please fill in the teammate's name, email, and role." } } }
                noDept: { value: { success: false, error: { code: MISSING_FIELDS, message: A Standard Admin must be assigned at least one department. } } }
                customerNotFound: { value: { success: false, error: { code: VALIDATION_ERROR, message: Your account could not be found. } } }
                noPortfolio: { value: { success: false, error: { code: VALIDATION_ERROR, message: "Your account isn't associated with a portfolio — contact support before inviting admins." } } }
                portfolioNotFound: { value: { success: false, error: { code: VALIDATION_ERROR, message: "Your account's portfolio could not be found — contact support before inviting admins." } } }
                refetchFailed: { value: { success: false, error: { code: VALIDATION_ERROR, message: The admin could not be created — please try again. } } }
        '409':
          description: This person is already an admin on your account.
          content:
            application/json:
              example: { success: false, error: { code: ADMIN_ALREADY_EXISTS, message: This person is already an admin on your account. } }
        '422':
          description: Invalid role, or a department id that doesn't belong to your account.
          content:
            application/json:
              examples:
                role: { value: { success: false, error: { code: INVALID_ROLE, message: "Please choose one of: Admin, RW Admin, RO Admin, Standard Admin." } } }
                dept: { value: { success: false, error: { code: INVALID_DEPARTMENT, message: One or more departments do not belong to your account. } } }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1.1/team-members/{email}:
    parameters: [ { name: email, in: path, required: true, schema: { type: string, format: email, example: jane@example.com } } ]
    get:
      tags: [Team Members]
      summary: Get a team member
      operationId: getTeamMember
      responses:
        '200':
          description: The team member, including `invitedAt` (only present on this single-item read).
          content:
            application/json:
              example: { success: true, data: { name: Jane Doe, email: jane@example.com, role: Admin, status: active, invitedAt: "2026-06-01T00:00:00.000Z" } }
        '404':
          description: No admin with that email.
          content:
            application/json:
              example: { success: false, error: { code: ADMIN_NOT_FOUND, message: We couldn't find an admin with that email on your account. } }
    put:
      tags: [Team Members]
      summary: Update a team member
      operationId: updateTeamMember
      description: "`email` itself isn't updatable."
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 1, maxLength: 100 }
                role: { type: string, enum: [Admin, "RW Admin", "RO Admin", "Standard Admin"] }
                departments: { type: array, items: { type: string } }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              example: { success: true, data: { name: Jane Doe, email: jane@example.com, role: "RW Admin", status: active } }
        '400':
          description: Demoting the account's only Admin, or a Standard Admin left with no departments.
          content:
            application/json:
              examples:
                demoteLastAdmin: { value: { success: false, error: { code: CANNOT_DEMOTE_LAST_ADMIN, message: "This is your account's only Admin — add another Admin before changing this one to Viewer." } } }
                noDept: { value: { success: false, error: { code: MISSING_FIELDS, message: A Standard Admin must be assigned at least one department. } } }
        '404':
          description: No admin with that email.
          content:
            application/json:
              example: { success: false, error: { code: ADMIN_NOT_FOUND, message: We couldn't find an admin with that email on your account. } }
        '422':
          description: Invalid role, or a department id that doesn't belong to your account.
          content:
            application/json:
              examples:
                role: { value: { success: false, error: { code: INVALID_ROLE, message: "Please choose one of: Admin, RW Admin, RO Admin, Standard Admin." } } }
                dept: { value: { success: false, error: { code: INVALID_DEPARTMENT, message: One or more departments do not belong to your account. } } }
        '500':
          description: The Auth0 role resync failed.
          content:
            application/json:
              example: { success: false, error: { code: INTERNAL_ERROR, message: "This admin's role could not be updated — please try again." } }
    delete:
      tags: [Team Members]
      summary: Remove a team member
      operationId: deleteTeamMember
      responses:
        '200':
          description: Removed.
          content:
            application/json:
              example: { success: true, data: { email: jane@example.com, status: removed } }
        '400':
          description: You tried to remove yourself, or your account's only Admin.
          content:
            application/json:
              examples:
                self: { value: { success: false, error: { code: CANNOT_REMOVE_SELF, message: "You can't remove your own admin access. Ask another admin to do this for you." } } }
                lastAdmin: { value: { success: false, error: { code: CANNOT_REMOVE_LAST_ADMIN, message: "You can't remove your account's only admin — add another admin first." } } }
        '404':
          description: No admin with that email.
          content:
            application/json:
              example: { success: false, error: { code: ADMIN_NOT_FOUND, message: We couldn't find an admin with that email on your account. } }
        '500':
          description: The Auth0 delete failed.
          content:
            application/json:
              example: { success: false, error: { code: INTERNAL_ERROR, message: This admin could not be removed — please try again. } }

  # ────────────────────────────── Async Status ────────────────────────────────
  /api/v3/transactions/{transactionId}:
    get:
      tags: [Async Status]
      summary: Poll the status of any v3 async operation
      operationId: getTransactionStatus
      description: |
        Universal status poll for any async endpoint that returns a
        `transactionId` — plan purchase, eSIM provisioning, top-up, port-in,
        subscription changes. **No authentication required** — the
        `transactionId` itself is the bearer, matching every other endpoint
        that hands one out.

        A `FAILED` transaction is still a `200` — check `data.status`, not the
        HTTP status. `data.errorCode` is one of the standard `ApiErrorCode`
        values when the failure is classifiable; `data.error` is a short,
        partner-safe message.

        `data.timestamp` is generated fresh on every request (it is when the
        response was built, not when the transaction record last changed) —
        don't confuse it with `data.createdAt` / `data.updatedAt`.
      security: []
      parameters: [ { name: transactionId, in: path, required: true, schema: { type: string, example: "64f1a2b3c4d5e6f7a8b9c0d1" } } ]
      responses:
        '200':
          description: The transaction's current status.
          content:
            application/json:
              examples:
                completed:
                  value:
                    success: true
                    data:
                      transactionId: "64f1a2b3c4d5e6f7a8b9c0d1"
                      status: COMPLETED
                      message: "Transaction completed successfully"
                      timestamp: "2026-07-10T10:32:00.500Z"
                      result: { mdn: "+12792044584", iccid: "89012345678901234567", qrCode: "https://…/qrcode.png" }
                      createdAt: "2026-07-10T10:30:00.000Z"
                      updatedAt: "2026-07-10T10:32:00.000Z"
                      processingStartedAt: "2026-07-10T10:30:01.000Z"
                      processingCompletedAt: "2026-07-10T10:32:00.000Z"
                      scheduledDate: null
                failed:
                  value:
                    success: true
                    data:
                      transactionId: "64f1a2b3c4d5e6f7a8b9c0d1"
                      status: FAILED
                      message: "Transaction failed"
                      timestamp: "2026-07-10T10:30:05.500Z"
                      error: "This plan is not available for purchase right now. Please contact support."
                      errorCode: PRICING_NOT_CONFIGURED
                      createdAt: "2026-07-10T10:30:00.000Z"
                      updatedAt: "2026-07-10T10:30:05.000Z"
        '404':
          description: Transaction not found, or the link has expired.
          content:
            application/json:
              example: { success: false, error: { code: NOT_FOUND, message: "We couldn't find that request, or the link has expired." } }
        '500':
          description: Status check failed.
          content:
            application/json:
              example: { success: false, error: { code: INTERNAL_ERROR, message: "Something went wrong while checking that request. Please try again." } }
