openapi: 3.1.0
info:
  title: G-Starlink Hub Partner API
  version: 1.0.0
  description: |
    B2B eSIM distribution platform API for partners and resellers.

    ## Authentication
    All endpoints (except token) require OAuth 2.0 Bearer token authentication.
    1. Generate API credentials (client_id + client_secret) yourself in the Partner Portal
    2. Exchange credentials for an access token via `POST /api/v1/oauth/token`
    3. Include the token in all requests: `Authorization: Bearer <access_token>`

    Tokens expire after 1 hour. Request a new one when expired.

    ## Error Format
    All errors follow [RFC 7807](https://tools.ietf.org/html/rfc7807) Problem Details:
    ```json
    {
      "type": "https://docs.gstarlink.com/errors/<code>",
      "title": "Human Readable Title",
      "status": 400,
      "detail": "Specific error description"
    }
    ```

    ## Rate Limiting
    All endpoints return rate limit headers:
    - `X-RateLimit-Limit` — requests per minute
    - `X-RateLimit-Remaining` — remaining requests
    - `X-RateLimit-Reset` — Unix timestamp when limit resets

    ## Pagination
    List endpoints use cursor-based pagination:
    ```
    GET /api/v1/orders?limit=20&cursor=<opaque_cursor>
    ```
    Response includes `pagination.next_cursor` for the next page.

    ## Idempotency
    Order creation requires an `Idempotency-Key` header to prevent duplicates.

    ## Service Scope — Recharge Face Value Disclaimer
    For prepaid SIM products and recharges, our service guarantee covers the
    **recharge face value only** (e.g. a $39 recharge credits $39 to the SIM).
    Plan inclusions attached to that face value — data allowance, bonus data,
    calls, validity — are set solely by the carrier and may change at any time
    without notice. In particular, carriers frequently run bonus-data
    promotions (e.g. a $39 plan showing 65GB as 25GB standard + 40GB bonus)
    where the bonus portion varies month to month or ends without notice.
    We accept no responsibility for differences between advertised and
    delivered plan inclusions; always verify current inclusions on the
    carrier's official website before resale.
  contact:
    name: G-Starlink Hub Support
    email: support@gstarlink.com
  license:
    name: Proprietary

servers:
  - url: https://api.gstarlink.com
    description: Production
  - url: https://esim-hub.vercel.app
    description: Production (alternate)

tags:
  - name: Authentication
    description: OAuth 2.0 token management
  - name: Products
    description: Browse eSIM product catalog
  - name: Orders
    description: Create and manage orders
  - name: Activations
    description: Physical SIM activation requests and status
  - name: KYC
    description: Passport / KYC pool management
  - name: eSIMs
    description: eSIM status and delivery
  - name: Inventory
    description: Product availability
  - name: Webhooks
    description: |
      Event notifications via HTTP POST to your registered endpoint.

      Supported events: `order.created`, `order.fulfilled`, `order.cancelled`, `esim.activated`, `esim.expired`, `payment.completed`, `payment.failed`, `activation.submitted`, `activation.confirmed`, `activation.activated`, `activation.failed`

      Webhook signature: `X-Webhook-Signature: t=<timestamp>,v1=<hmac_sha256_signature>`
  - name: Catalog
    description: E-commerce catalog sync

paths:
  # ============================================================================
  # Authentication
  # ============================================================================
  /api/v1/oauth/token:
    post:
      operationId: createToken
      tags: [Authentication]
      summary: Get access token
      description: |
        Exchange client credentials for a Bearer access token.
        Supports both JSON and form-urlencoded request bodies.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TokenRequest'
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/TokenRequest'
      responses:
        '200':
          description: Access token issued
          headers:
            Cache-Control:
              schema:
                type: string
                example: no-store
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TokenResponse'
        '400':
          description: Invalid request or scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OAuthError'
        '401':
          description: Invalid credentials
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OAuthError'
        '403':
          description: Partner account suspended
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OAuthError'

  # ============================================================================
  # Products
  # ============================================================================
  /api/v1/products:
    get:
      operationId: listProducts
      tags: [Products]
      summary: List products
      description: Browse available eSIM products with filtering and pagination.
      security:
        - bearerAuth: [read:products]
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            minimum: 1
            maximum: 100
        - name: cursor
          in: query
          schema:
            type: string
          description: Opaque pagination cursor from previous response
        - name: region
          in: query
          schema:
            type: string
          description: Filter by region (e.g. "Global", "Europe", "Asia")
        - name: type
          in: query
          schema:
            type: string
          description: Filter by eSIM type
        - name: status
          in: query
          schema:
            type: string
            default: active
        - name: sort_by
          in: query
          schema:
            type: string
            default: created_at
        - name: sort_order
          in: query
          schema:
            type: string
            enum: [asc, desc]
            default: desc
      responses:
        '200':
          description: Product list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Product'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
        '400':
          $ref: '#/components/responses/InvalidCursor'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /api/v1/products/{id}:
    get:
      operationId: getProduct
      tags: [Products]
      summary: Get product details
      security:
        - bearerAuth: [read:products]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Product details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductDetail'
        '404':
          $ref: '#/components/responses/NotFound'
        '401':
          $ref: '#/components/responses/Unauthorized'

  # ============================================================================
  # Orders
  # ============================================================================
  /api/v1/orders:
    post:
      operationId: createOrder
      tags: [Orders]
      summary: Create order
      description: |
        Create a new order. Requires `Idempotency-Key` header to prevent duplicates.
        If the same key is reused, the original order is returned with `Idempotent-Replayed: true` header.

        **Payment:** production orders are charged atomically to the partner's
        pre-funded wallet and are created as `paid`. If the balance is
        insufficient the order is NOT created and `402 Insufficient Balance` is
        returned with the shortfall. Sandbox orders simulate payment — the
        wallet is never charged. Check the balance with `GET /api/v1/balance`.
      security:
        - bearerAuth: [write:orders]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema:
            type: string
          description: Unique key to prevent duplicate orders
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateOrderRequest'
      responses:
        '201':
          description: Order created
          headers:
            Location:
              schema:
                type: string
                example: /api/v1/orders/550e8400-e29b-41d4-a716-446655440000
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '200':
          description: Idempotent replay (order already exists)
          headers:
            Idempotent-Replayed:
              schema:
                type: string
                example: 'true'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '400':
          description: Validation error, invalid product, or missing activation request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: Wallet balance too low — no order created and no funds taken
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InsufficientBalance'
        '429':
          $ref: '#/components/responses/RateLimited'

    get:
      operationId: listOrders
      tags: [Orders]
      summary: List orders
      description: List your orders with optional status filter and cursor pagination.
      security:
        - bearerAuth: [read:orders]
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            minimum: 1
            maximum: 100
        - name: cursor
          in: query
          schema:
            type: string
        - name: status
          in: query
          schema:
            type: string
            enum: [pending_payment, paid, waiting_review, fulfilled, failed, cancelled]
      responses:
        '200':
          description: Order list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Order'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /api/v1/orders/{id}:
    get:
      operationId: getOrder
      tags: [Orders]
      summary: Get order with fulfillment details
      description: Returns order details including line items and fulfillment information (shipping tracking, eSIM codes, etc.)
      security:
        - bearerAuth: [read:orders]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Order with fulfillment
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderWithFulfillment'
        '404':
          $ref: '#/components/responses/NotFound'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v1/orders/{id}/esims:
    get:
      operationId: getOrderEsims
      tags: [eSIMs]
      summary: Get eSIMs for an order
      description: Returns eSIM delivery details (ICCID, activation code, QR code URL) for a fulfilled order.
      security:
        - bearerAuth: [read:esims]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: eSIM delivery details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderEsims'
        '400':
          description: Order not yet fulfilled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '404':
          $ref: '#/components/responses/NotFound'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v1/orders/{id}/activation-status:
    get:
      operationId: getActivationStatus
      tags: [Orders]
      summary: Get activation status for an order
      description: Returns the status of all scheduled activations associated with an order.
      security:
        - bearerAuth: [read:orders]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Activation status details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ActivationStatusResponse'
        '404':
          $ref: '#/components/responses/NotFound'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v1/orders/bulk:
    post:
      operationId: createBulkOrders
      tags: [Orders]
      summary: Create bulk orders
      description: |
        Create up to 100 orders in a single request. Each order is processed independently;
        individual failures do not affect other orders in the batch.
      security:
        - bearerAuth: [write:orders]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkOrderRequest'
      responses:
        '200':
          description: Bulk order results
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkOrderResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'

  # ============================================================================
  # Activations (Physical SIM)
  # ============================================================================
  /api/v1/activations:
    post:
      operationId: submitActivation
      tags: [Activations]
      summary: Submit physical SIM activation
      description: |
        Submit a physical SIM card activation request with KYC data.
        The system auto-detects the carrier from the ICCID prefix:
        - `896103*` = Lycamobile
        - `58*` = Optus

        **Currently supported carriers: Lycamobile and Optus.** ICCIDs
        for other carriers return `422 Carrier Not Supported` with a
        `supported_carriers` list.

        KYC requirements differ by carrier (all listed fields required):
        - **Lycamobile**: first_name, last_name, passport_no,
          passport_country, passport_expiry, date_of_birth, address,
          sim_last4, puk
        - **Optus**: first_name, last_name, passport_no,
          passport_country, passport_expiry, date_of_birth, address,
          title
      security:
        - bearerAuth: [write:orders]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateActivationRequest'
      responses:
        '201':
          description: Activation request created
          headers:
            Location:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ActivationResult'
        '400':
          description: Validation error or missing KYC fields
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '409':
          description: Duplicate ICCID or date conflict
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

    get:
      operationId: listActivations
      tags: [Activations]
      summary: List activation requests
      description: List your physical SIM activation requests with optional filters.
      security:
        - bearerAuth: [read:orders]
      parameters:
        - name: iccid
          in: query
          schema:
            type: string
          description: Filter by SIM number
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, processing, completed, failed, cancelled]
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            maximum: 100
      responses:
        '200':
          description: Activation request list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ActivationDetail'
                  count:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /api/v1/activations/{id}:
    get:
      operationId: getActivation
      tags: [Activations]
      summary: Get activation details
      description: Get detailed status of a specific activation request, including assigned mobile number and any error information.
      security:
        - bearerAuth: [read:orders]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Activation details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ActivationDetail'
        '404':
          $ref: '#/components/responses/NotFound'
        '401':
          $ref: '#/components/responses/Unauthorized'

    patch:
      operationId: rescheduleActivation
      tags: [Activations]
      summary: Reschedule an activation request
      description: |
        Edit the requested activation date and/or note on an in-flight
        activation request. At least one of the two fields is required.

        **Only editable while `status = "activation_requested"`.** Once ops
        has submitted the request to the carrier (or it has moved past that
        state for any other reason), this returns `409` naming the current
        status — coordinate with support instead. No KYC or customer fields
        can be changed here; those go through the KYC pool endpoints.

        Not found, or the activation belongs to another partner — `404`
        (existence is never leaked).
      security:
        - bearerAuth: [write:orders]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ActivationRescheduleRequest'
      responses:
        '200':
          description: Activation rescheduled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ActivationRescheduleResult'
        '400':
          description: Validation error (no editable field, or a bad date)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Not editable — activation is no longer 'activation_requested'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /api/v1/activations/{id}/cancel:
    post:
      operationId: cancelActivation
      tags: [Activations]
      summary: Cancel an activation request
      description: |
        Cancel an in-flight activation request.

        **Only cancellable while `status = "activation_requested"`.** Once
        ops has submitted the request to the carrier, this returns `409`
        naming the current status — contact support instead. Cancelling
        atomically releases any KYC-pool passport draw (the passport's
        per-brand use count and `assigned_request_id` are cleared, so it
        never falsely counts toward its 4-use cap) and rolls the SIM's
        activation status back to `Inactive`, so it becomes resubmittable.

        Not found, or the activation belongs to another partner — `404`
        (existence is never leaked).
      security:
        - bearerAuth: [write:orders]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ActivationCancelRequest'
      responses:
        '200':
          description: Activation cancelled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ActivationCancelResult'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Not cancellable — activation is no longer 'activation_requested'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /api/v1/activations/by-iccid/{iccid}/cancel:
    post:
      operationId: cancelActivationByIccid
      tags: [Activations]
      summary: Cancel an activation request, looked up by ICCID
      description: |
        Same atomic core as `POST /api/v1/activations/{id}/cancel` — for
        callers (e.g. ICC's automation) that hold the ICCID rather than our
        internal activation id, so no extra `GET /api/v1/activations` lookup
        is needed first.

        **Only cancellable while `status = "activation_requested"`.** Once
        ops has submitted the request to the carrier, this returns `409`
        naming the current status — contact support instead. Cancelling
        atomically releases any KYC-pool passport draw and rolls the SIM's
        activation status back to `Inactive`, so it becomes resubmittable.

        No open (in-flight) activation for this ICCID under your account —
        whether it doesn't exist, belongs to another partner, or was never
        submitted — `404` (existence is never leaked).
      security:
        - bearerAuth: [write:orders]
      parameters:
        - name: iccid
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ActivationCancelRequest'
      responses:
        '200':
          description: Activation cancelled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ActivationCancelByIccidResult'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Not cancellable — activation is no longer 'activation_requested'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /api/v1/activations/by-iccid/{iccid}:
    patch:
      operationId: rescheduleActivationByIccid
      tags: [Activations]
      summary: Reschedule an activation request, looked up by ICCID
      description: |
        Same guarded-update core as `PATCH /api/v1/activations/{id}` — for
        callers (e.g. ICC's automation) that hold the ICCID rather than our
        internal activation id. At least one of `requested_activation_date`
        / `note` is required.

        **Only editable while `status = "activation_requested"`.** Once ops
        has submitted the request to the carrier (or it has moved past that
        state for any other reason), this returns `409` naming the current
        status. No KYC or customer fields can be changed here.

        No open (in-flight) activation for this ICCID under your account —
        whether it doesn't exist, belongs to another partner, or was never
        submitted — `404` (existence is never leaked).
      security:
        - bearerAuth: [write:orders]
      parameters:
        - name: iccid
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ActivationRescheduleRequest'
      responses:
        '200':
          description: Activation rescheduled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ActivationRescheduleByIccidResult'
        '400':
          description: Validation error (no editable field, or a bad date)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Not editable — activation is no longer 'activation_requested'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /api/v1/activations/bulk/cancel:
    post:
      operationId: bulkCancelActivations
      tags: [Activations]
      summary: Cancel a batch of activation requests
      description: |
        Cancel up to 200 activation requests in one call. Each item is
        cancelled through the same atomic core as the single-cancel
        endpoint (`POST /api/v1/activations/{id}/cancel`) — one item's
        failure never touches the others.

        Identify items with `activation_ids` (our internal UUIDs), `iccids`
        (for callers like ICC's automation that hold ICCIDs instead), or a
        mix of both — combined, up to 200. ICCIDs are resolved to their
        in-flight activation internally; outcomes are keyed by whichever
        identifier you supplied for that item (`activation_id` or `iccid`).

        **Partial success is normal and returns `200`.** Ids/ICCIDs that
        don't exist, don't belong to your account, or (for an ICCID) have no
        open activation, come back as an outcome with
        `reason: "not_found"` rather than failing the whole batch. Items that
        are no longer `activation_requested` come back as
        `reason: "not_cancellable:<status>"`.
      security:
        - bearerAuth: [write:orders]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkActivationCancelRequest'
      responses:
        '200':
          description: Batch processed (see per-id outcomes; partial success is normal)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkActivationCancelResult'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '422':
          description: Partner not provisioned (no agent record)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /api/v1/activations/bulk/reschedule:
    post:
      operationId: bulkRescheduleActivations
      tags: [Activations]
      summary: Reschedule a batch of activation requests
      description: |
        Edit the requested activation date and/or note on up to 200
        activation requests in one call. Each item identifies its activation
        with EXACTLY ONE of `activation_id` (our internal UUID) or `iccid`
        (for callers like ICC's automation that hold ICCIDs instead), needs
        at least one of `requested_activation_date` / `note`, and is applied
        through its own guarded update — one item's failure never touches
        the others. Each outcome is keyed by whichever identifier the item
        supplied.

        **Partial success is normal and returns `200`.** Ids/ICCIDs that
        don't exist, don't belong to your account, or (for an ICCID) have no
        open activation, come back as an outcome with
        `reason: "not_found"`. Items that are no longer
        `activation_requested` come back as
        `reason: "not_editable:<status>"` — the same "requested-only"
        gate used everywhere else in the activation lifecycle.
      security:
        - bearerAuth: [write:orders]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkActivationRescheduleRequest'
      responses:
        '200':
          description: Batch processed (see per-id outcomes; partial success is normal)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkActivationRescheduleResult'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '422':
          description: Partner not provisioned (no agent record)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  # ============================================================================
  # KYC
  # ============================================================================
  /api/v1/kyc/passports:
    post:
      operationId: importKycPassports
      tags: [KYC]
      summary: Import passports into your KYC pool
      description: |
        Bulk-add passports into your own KYC pool (up to 200 per request).
        The pool is what the activation flow draws from when a bulk activation
        line carries no inline passport.

        **Imported passports are UNVERIFIED.** Each record is stored with
        `verification_status = "pending"`. Our operations team must verify a
        passport before the auto-draw will assign it to an activation — the
        draw only selects verified (`onshore`) records. Importing here is not a
        substitute for KYC verification.

        Records are pinned to your own partner account; you can only write into
        your own pool. Duplicate passport numbers — whether already in your pool
        or repeated within the same request — are skipped (see
        `skipped_duplicates`).
      security:
        - bearerAuth: [write:orders]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/KycPassportImportRequest'
      responses:
        '201':
          description: Passports imported (unverified)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KycPassportImportResult'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '422':
          description: Partner not provisioned (no agent record)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

    get:
      operationId: listKycPassports
      tags: [KYC]
      summary: List your KYC pool passports
      description: |
        List the passports in your own KYC pool. Returns only the fields you
        submitted plus a neutral outward `status` of `received` — our internal
        verification and usage state is never exposed. Withdrawn (soft-deleted)
        records are excluded.
      security:
        - bearerAuth: [read:orders]
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 100
            maximum: 200
      responses:
        '200':
          description: Passport list
          content:
            application/json:
              schema:
                type: object
                properties:
                  records:
                    type: array
                    items:
                      $ref: '#/components/schemas/KycPassportListItem'
                  count:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /api/v1/kyc/passports/{id}:
    patch:
      operationId: updateKycPassport
      tags: [KYC]
      summary: Edit a KYC pool passport
      description: |
        Edit one of your pool passports. **Only allowed while the passport has
        never been used for an activation** — once used it is locked and must be
        changed by our operations team (`409 Passport In Use`). Editing identity
        fields (passport number, name, expiry) resets our verification, so the
        record must be re-verified before it can be drawn again.
      security:
        - bearerAuth: [write:orders]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/KycPassportUpdateRequest'
      responses:
        '200':
          description: Passport updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  id:
                    type: string
                    format: uuid
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Passport already used (locked) or number conflict
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

    delete:
      operationId: withdrawKycPassport
      tags: [KYC]
      summary: Withdraw (soft-delete) a KYC pool passport
      description: |
        Withdraw one of your pool passports. This is a **soft delete** — the
        record leaves your pool but is retained for compliance; it is never
        physically deleted. **Only allowed while the passport has never been
        used for an activation** — once used it is locked (`409 Passport In
        Use`) and must be handled by our operations team.
      security:
        - bearerAuth: [write:orders]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Passport withdrawn
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  id:
                    type: string
                    format: uuid
                  status:
                    type: string
                    example: withdrawn
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Passport already used (locked)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  # ============================================================================
  # eSIMs
  # ============================================================================
  /api/v1/balance:
    get:
      operationId: getBalance
      tags: [Wallet]
      summary: Get wallet balance
      description: |
        Current pre-funded wallet balance per currency. Orders are charged in the
        product's currency, so the matching wallet must be funded.
      security:
        - bearerAuth: [read:orders]
      responses:
        '200':
          description: Wallet balances
          content:
            application/json:
              schema:
                type: object
                properties:
                  balances:
                    type: array
                    items:
                      type: object
                      properties:
                        currency:
                          type: string
                          example: USD
                        balance_cents:
                          type: integer
                          example: 250000
                        last_transaction_at:
                          type: [string, "null"]
                          format: date-time
                  count:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /api/v1/esims/{iccid}/status:
    get:
      operationId: getEsimStatus
      tags: [eSIMs]
      summary: Get eSIM status
      description: Check the activation status, data usage, and location of an eSIM by ICCID.
      security:
        - bearerAuth: [read:esims]
      parameters:
        - name: iccid
          in: path
          required: true
          schema:
            type: string
            pattern: '^\d{19,20}$'
          description: ICCID (19-20 digits)
      responses:
        '200':
          description: eSIM status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EsimStatus'
        '400':
          description: Invalid ICCID format
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '403':
          description: Not authorized to access this eSIM
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '404':
          $ref: '#/components/responses/NotFound'
        '401':
          $ref: '#/components/responses/Unauthorized'

  # ============================================================================
  # Inventory
  # ============================================================================
  /api/v1/inventory/status:
    get:
      operationId: getInventoryStatus
      tags: [Inventory]
      summary: Check product availability
      description: Check real-time stock and availability status for products.
      security:
        - bearerAuth: [read:products]
      parameters:
        - name: product_ids
          in: query
          schema:
            type: string
          description: Comma-separated product UUIDs
        - name: skus
          in: query
          schema:
            type: string
          description: Comma-separated SKU codes
        - name: region
          in: query
          schema:
            type: string
          description: Filter by region
      responses:
        '200':
          description: Inventory status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InventoryStatusResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'

  # ============================================================================
  # Webhooks
  # ============================================================================
  /api/v1/webhooks:
    post:
      operationId: createWebhook
      tags: [Webhooks]
      summary: Create webhook
      description: |
        Register a webhook endpoint to receive event notifications.
        The signing secret is returned only once in the response — store it securely.
      security:
        - bearerAuth: [write:webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateWebhookRequest'
      responses:
        '201':
          description: Webhook created with signing secret
          headers:
            Location:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookWithSecret'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'

    get:
      operationId: listWebhooks
      tags: [Webhooks]
      summary: List webhooks
      security:
        - bearerAuth: [read:webhooks]
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            maximum: 100
        - name: cursor
          in: query
          schema:
            type: string
        - name: status
          in: query
          schema:
            type: string
            enum: [active, disabled, failed]
        - name: environment
          in: query
          schema:
            type: string
            enum: [sandbox, production]
      responses:
        '200':
          description: Webhook list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Webhook'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v1/webhooks/{id}:
    get:
      operationId: getWebhook
      tags: [Webhooks]
      summary: Get webhook details
      security:
        - bearerAuth: [read:webhooks]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Webhook details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDetail'
        '404':
          $ref: '#/components/responses/NotFound'
        '401':
          $ref: '#/components/responses/Unauthorized'

    delete:
      operationId: deleteWebhook
      tags: [Webhooks]
      summary: Delete webhook
      security:
        - bearerAuth: [write:webhooks]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Webhook deleted
        '404':
          $ref: '#/components/responses/NotFound'
        '401':
          $ref: '#/components/responses/Unauthorized'

  # ============================================================================
  # Catalog Sync
  # ============================================================================
  /api/v1/catalog/sync:
    get:
      operationId: syncCatalog
      tags: [Catalog]
      summary: Get product catalog for e-commerce
      description: |
        Export product catalog formatted for Shopify, WooCommerce, or generic use.
        Includes suggested retail pricing with configurable markup.
      security:
        - bearerAuth: [read:products]
      parameters:
        - name: format
          in: query
          schema:
            type: string
            enum: [shopify, woocommerce, generic]
            default: generic
        - name: include_images
          in: query
          schema:
            type: string
            enum: ['true', 'false']
            default: 'true'
        - name: region
          in: query
          schema:
            type: string
        - name: type
          in: query
          schema:
            type: string
        - name: markup
          in: query
          schema:
            type: number
            default: 30
          description: Markup percentage for suggested retail price
      responses:
        '200':
          description: Product catalog
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CatalogSyncResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'

# ============================================================================
# Components
# ============================================================================
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: OAuth 2.0 access token obtained from POST /api/v1/oauth/token

  responses:
    Unauthorized:
      description: Missing or invalid authentication
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
    InvalidCursor:
      description: Invalid pagination cursor
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
    RateLimited:
      description: Rate limit exceeded
      headers:
        X-RateLimit-Limit:
          schema:
            type: integer
        X-RateLimit-Remaining:
          schema:
            type: integer
        X-RateLimit-Reset:
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'

  schemas:
    # ---- Auth ----
    TokenRequest:
      type: object
      required: [grant_type, client_id, client_secret]
      properties:
        grant_type:
          type: string
          enum: [client_credentials]
        client_id:
          type: string
          minLength: 10
          example: sk_live_abc123def456
        client_secret:
          type: string
          minLength: 20
        scope:
          type: string
          description: Space-separated scopes
          example: read:products write:orders read:orders read:esims

    TokenResponse:
      type: object
      properties:
        access_token:
          type: string
        token_type:
          type: string
          enum: [Bearer]
        expires_in:
          type: integer
          example: 3600
        scope:
          type: string

    OAuthError:
      type: object
      properties:
        error:
          type: string
          enum: [invalid_request, invalid_client, invalid_scope, unauthorized_client, server_error]
        error_description:
          type: string

    # ---- Common ----
    InsufficientBalance:
      type: object
      properties:
        type:
          type: string
          example: https://docs.gstarlink.com/errors/insufficient_balance
        title:
          type: string
          example: Insufficient Balance
        status:
          type: integer
          example: 402
        detail:
          type: string
        currency:
          type: string
          example: USD
        balance_cents:
          type: integer
          description: Current wallet balance
          example: 905
        required_cents:
          type: integer
          description: Order total
          example: 8190
        shortfall_cents:
          type: integer
          description: How much to top up
          example: 7285

    ProblemDetail:
      type: object
      description: RFC 7807 Problem Details
      properties:
        type:
          type: string
          example: https://docs.gstarlink.com/errors/not_found
        title:
          type: string
          example: Not Found
        status:
          type: integer
          example: 404
        detail:
          type: string
        instance:
          type: string
        errors:
          type: object
          additionalProperties:
            type: array
            items:
              type: string

    Pagination:
      type: object
      properties:
        has_more:
          type: boolean
        next_cursor:
          type: [string, "null"]
        count:
          type: integer

    # ---- Products ----
    Product:
      type: object
      properties:
        id:
          type: string
          format: uuid
        sku:
          type: string
        name:
          type: string
        description:
          type: [string, "null"]
        region:
          type: string
          example: Asia
        coverage:
          type: array
          items:
            type: string
          example: [Japan, South Korea]
        data_amount_mb:
          type: number
        validity_days:
          type: integer
        price_cents:
          type: integer
          description: Wholesale price in cents
        currency:
          type: string
          example: USD
        type:
          type: string
          enum: [data_only, voice_data]
        status:
          type: string
          enum: [active, inactive, out_of_stock]
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    ProductDetail:
      allOf:
        - $ref: '#/components/schemas/Product'
        - type: object
          properties:
            features:
              type: array
              items:
                type: [string, "null"]
            activation_policy:
              type: [string, "null"]
            network_type:
              type: [string, "null"]
              example: 4G/LTE
            compatible_devices:
              type: array
              items:
                type: [string, "null"]

    # ---- Orders ----
    CreateOrderRequest:
      type: object
      required: [line_items]
      properties:
        line_items:
          type: array
          minItems: 1
          maxItems: 50
          items:
            type: object
            required: [product_id, quantity]
            properties:
              product_id:
                type: string
                format: uuid
              quantity:
                type: integer
                minimum: 1
                maximum: 10000
        activation_requests:
          type: array
          description: Required for scheduled delivery products
          items:
            type: object
            required: [product_id, customer_email, customer_name, activation_date]
            properties:
              product_id:
                type: string
                format: uuid
              customer_email:
                type: string
                format: email
              customer_name:
                type: string
                maxLength: 100
              customer_phone:
                type: string
                maxLength: 20
              eid_number:
                type: string
                pattern: '^\d{32}$'
                description: Required for EID push delivery
              activation_date:
                type: string
                format: date
              timezone:
                type: string
                default: UTC
                maxLength: 50
              selected_plan_id:
                type: string
                format: uuid
        metadata:
          type: object
          additionalProperties: true

    Order:
      type: object
      properties:
        id:
          type: string
          format: uuid
        order_number:
          type: string
          example: ORD-20260413-A1B2C
        partner_id:
          type: string
          format: uuid
        product_id:
          type: string
        product_sku:
          type: string
        quantity:
          type: integer
        unit_price_cents:
          type: integer
        total_price_cents:
          type: integer
        currency:
          type: string
        status:
          type: string
          enum: [pending_payment, paid, waiting_review, fulfilled, failed, cancelled]
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    OrderWithFulfillment:
      type: object
      properties:
        id:
          type: string
          format: uuid
        order_number:
          type: string
        status:
          type: string
        total_amount_cents:
          type: integer
        currency:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        line_items:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              product_name:
                type: string
              product_sku:
                type: string
              product_type:
                type: string
                enum: [physical_sim, esim_qr_pregen, esim_qr_api, esim_eid_push]
              quantity:
                type: integer
              price_per_unit_cents:
                type: integer
              subtotal_cents:
                type: integer
              fulfillment:
                type: object
                description: Varies by product_type (shipping tracking, voucher codes, or eSIM LPA codes)

    OrderEsims:
      type: object
      properties:
        order_id:
          type: string
          format: uuid
        esims:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              iccid:
                type: string
              eid:
                type: [string, "null"]
              smdp_address:
                type: string
              activation_code:
                type: string
                description: LPA activation string
                example: "LPA:1$smdp.example.com$MATCHING_ID"
              qr_code_url:
                type: string
                format: uri
              status:
                type: string
                enum: [active, inactive, suspended]
              activation_policy:
                type: string
              created_at:
                type: string
                format: date-time
              activated_at:
                type: [string, "null"]
                format: date-time
        count:
          type: integer

    ActivationStatusResponse:
      type: object
      properties:
        order_id:
          type: string
          format: uuid
        order_number:
          type: string
        has_scheduled_activations:
          type: boolean
        total_activations:
          type: integer
        pending_count:
          type: integer
        submitted_count:
          type: integer
        activated_count:
          type: integer
        failed_count:
          type: integer
        activations:
          type: array
          items:
            type: object
            properties:
              item_id:
                type: string
                format: uuid
              product_name:
                type: string
              product_sku:
                type: string
              status:
                type: string
                enum: [pending, submitted, confirmed, activated, failed, cancelled]
              customer_email:
                type: string
              customer_name:
                type: string
              activation_date:
                type: string
                format: date
              timezone:
                type: string
              eid_number:
                type: string
              error_message:
                type: string
              submitted_at:
                type: string
                format: date-time
              activated_at:
                type: string
                format: date-time
              created_at:
                type: string
                format: date-time

    BulkOrderRequest:
      type: object
      required: [orders]
      properties:
        orders:
          type: array
          minItems: 1
          maxItems: 100
          items:
            type: object
            required: [external_order_id, line_items, customer]
            properties:
              external_order_id:
                type: string
                maxLength: 255
                description: Your platform's order ID
              line_items:
                type: array
                minItems: 1
                maxItems: 50
                items:
                  type: object
                  required: [product_id, quantity]
                  properties:
                    product_id:
                      type: string
                      format: uuid
                    quantity:
                      type: integer
                      minimum: 1
                      maximum: 100
              customer:
                type: object
                required: [email]
                properties:
                  email:
                    type: string
                    format: email
                  name:
                    type: string
                    maxLength: 255
                  phone:
                    type: string
                    maxLength: 50
              metadata:
                type: object
                additionalProperties: true

    BulkOrderResponse:
      type: object
      properties:
        results:
          type: array
          items:
            type: object
            properties:
              external_order_id:
                type: string
              gstarlink_order_id:
                type: string
                format: uuid
              status:
                type: string
                enum: [success, failed]
              error:
                type: object
                properties:
                  code:
                    type: string
                  message:
                    type: string
        summary:
          type: object
          properties:
            total:
              type: integer
            successful:
              type: integer
            failed:
              type: integer

    # ---- Activations ----
    CreateActivationRequest:
      type: object
      required: [iccid, activation_date, first_name, last_name, passport_no, passport_country]
      properties:
        iccid:
          type: string
          pattern: '^\d{13,20}$'
          description: SIM number (13-20 digits). Carrier auto-detected from prefix.
          example: '5825004421406'
        activation_date:
          type: string
          format: date
          description: Activation date in Sydney timezone
          example: '2026-04-14'
        first_name:
          type: string
          example: John
        last_name:
          type: string
          example: Smith
        title:
          type: string
          enum: [Mr, Mrs, Ms, Miss, Dr]
          description: Honorific title. Required for Optus, not needed for Lycamobile.
        customer_email:
          type: string
          format: email
        customer_phone:
          type: string
          maxLength: 20
        date_of_birth:
          type: string
          format: date
          example: '1990-01-15'
        passport_no:
          type: string
          example: E12345678
        passport_country:
          type: string
          description: ISO country code
          example: CN
        passport_expiry:
          type: string
          format: date
          example: '2030-01-15'
        address:
          type: string
          description: Street address
          example: 1 Daly St
        suburb:
          type: string
          example: Darwin City
        state:
          type: string
          enum: [NSW, VIC, QLD, SA, WA, TAS, NT, ACT]
        postcode:
          type: string
          pattern: '^\d{4}$'
          example: '0800'
        puk:
          type: string
          description: PUK code (required for Lycamobile only)
        plan_name:
          type: string
        note:
          type: string
          maxLength: 500
        confirm_date_change:
          type: boolean
          description: Set true to override a conflicting pending activation for the same ICCID

    ActivationResult:
      type: object
      properties:
        id:
          type: string
          format: uuid
        iccid:
          type: string
        carrier:
          type: [string, "null"]
          example: Lycamobile
        activation_date:
          type: string
          format: date
        customer_name:
          type: string
        status:
          type: string
        processing_status:
          type: [string, "null"]
        processing_mode:
          type: string
          enum: [immediate, scheduled]
        message:
          type: string
        created_at:
          type: string
          format: date-time

    KycPassportImportRequest:
      type: object
      required: [records]
      properties:
        records:
          type: array
          minItems: 1
          maxItems: 200
          items:
            type: object
            required: [first_name, last_name, passport_no]
            properties:
              title:
                type: [string, "null"]
                example: Mr
              first_name:
                type: string
                example: Alice
              last_name:
                type: string
                example: Traveller
              date_of_birth:
                type: [string, "null"]
                format: date
                description: Literal calendar date, YYYY-MM-DD.
                example: '1990-01-01'
              passport_no:
                type: string
                minLength: 3
                example: X1234567
              passport_country:
                type: [string, "null"]
                description: ISO country code or name; normalized on import.
                example: GB
              passport_expiry_date:
                type: [string, "null"]
                format: date
                example: '2030-01-01'
        group_label:
          type: [string, "null"]
          maxLength: 200
          description: Optional label to tag this batch (defaults to "api-import").
          example: 2026-q3-batch

    KycPassportImportResult:
      type: object
      properties:
        ok:
          type: boolean
          example: true
        created:
          type: integer
          description: Number of new pool records inserted.
          example: 50
        skipped_duplicates:
          type: integer
          description: Records skipped because the passport number already existed in your pool or repeated within this request.
          example: 2
        verification_status:
          type: string
          description: The status every imported record is written with — always "pending" until ops verifies it.
          enum: [pending]
          example: pending

    KycPassportListItem:
      type: object
      description: A passport in your pool, showing only your own submitted data plus a neutral outward status.
      properties:
        id:
          type: string
          format: uuid
        title:
          type: [string, "null"]
        first_name:
          type: string
        last_name:
          type: string
        date_of_birth:
          type: [string, "null"]
          format: date
        passport_no:
          type: string
        passport_country:
          type: [string, "null"]
        passport_expiry_date:
          type: [string, "null"]
          format: date
        group_label:
          type: [string, "null"]
        created_at:
          type: string
          format: date-time
        status:
          type: string
          description: Neutral outward status — always "received" for records in your pool. Our internal verification/usage state is never exposed.
          enum: [received]
          example: received

    KycPassportUpdateRequest:
      type: object
      minProperties: 1
      description: Any subset of the editable identity fields (at least one).
      properties:
        title:
          type: [string, "null"]
        first_name:
          type: string
          minLength: 1
        last_name:
          type: string
          minLength: 1
        date_of_birth:
          type: [string, "null"]
          format: date
        passport_no:
          type: string
          minLength: 3
        passport_country:
          type: [string, "null"]
        passport_expiry_date:
          type: [string, "null"]
          format: date

    ActivationDetail:
      type: object
      properties:
        id:
          type: string
          format: uuid
        iccid:
          type: string
        carrier:
          type: [string, "null"]
        activation_date:
          type: string
          format: date
        customer_name:
          type: string
        customer_email:
          type: [string, "null"]
        status:
          type: string
          enum: [pending, processing, completed, failed, cancelled, rejected]
        processing_status:
          type: [string, "null"]
          description: Carrier processing status
          enum: [pending, processing, completed, failed, manual_required]
        mobile_number:
          type: [string, "null"]
          description: Assigned mobile number (available after successful activation)
          example: '0402033268'
        confirmation_number:
          type: [string, "null"]
        error_type:
          type: [string, "null"]
        error_message:
          type: [string, "null"]
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    ActivationCancelRequest:
      type: object
      properties:
        reason:
          type: [string, "null"]
          maxLength: 500
          description: Optional free-text reason, recorded on the activation's metadata.

    ActivationCancelResult:
      type: object
      properties:
        ok:
          type: boolean
          example: true
        id:
          type: string
          format: uuid
        status:
          type: string
          enum: [cancelled]
          example: cancelled

    ActivationRescheduleRequest:
      type: object
      description: At least one of the two fields is required.
      properties:
        requested_activation_date:
          type: string
          format: date
          description: Literal calendar date, YYYY-MM-DD (destination-local, never timezone-shifted).
          example: '2026-08-20'
        note:
          type: string
          maxLength: 500

    ActivationRescheduleResult:
      type: object
      properties:
        ok:
          type: boolean
          example: true
        id:
          type: string
          format: uuid

    ActivationCancelByIccidResult:
      type: object
      properties:
        ok:
          type: boolean
          example: true
        iccid:
          type: string
        activation_id:
          type: string
          format: uuid
          description: The internal activation id that was resolved from the ICCID and cancelled.
        status:
          type: string
          enum: [cancelled]
          example: cancelled

    ActivationRescheduleByIccidResult:
      type: object
      properties:
        ok:
          type: boolean
          example: true
        iccid:
          type: string
        activation_id:
          type: string
          format: uuid
          description: The internal activation id that was resolved from the ICCID and updated.

    BulkActivationCancelRequest:
      type: object
      description: |
        At least one of `activation_ids` / `iccids` is required; combined,
        the two arrays must not exceed 200 items.
      properties:
        activation_ids:
          type: array
          maxItems: 200
          items:
            type: string
            format: uuid
        iccids:
          type: array
          maxItems: 200
          items:
            type: string
          description: |
            ICCIDs to resolve and cancel — for callers (e.g. ICC's
            automation) that hold ICCIDs instead of our internal activation
            ids. Each ICCID is resolved to its current in-flight activation
            request, agent-scoped to your account.
        reason:
          type: [string, "null"]
          maxLength: 500
          description: Optional free-text reason applied to every cancelled item.

    BulkActivationCancelOutcome:
      type: object
      description: Exactly one of `activation_id` / `iccid` is present, matching whichever identifier the item was supplied as.
      properties:
        activation_id:
          type: string
          format: uuid
        iccid:
          type: string
        status:
          type: string
          enum: [cancelled, error]
        reason:
          type: string
          description: |
            Present when status is "error". One of "not_found" (doesn't
            exist, isn't yours, or — for an ICCID — has no open activation),
            "not_cancellable:<status>" (already past activation_requested),
            or "error" (unexpected failure).
          example: 'not_cancellable:processing'

    BulkActivationCancelResult:
      type: object
      properties:
        cancelled:
          type: integer
          example: 18
        errors:
          type: integer
          example: 2
        outcomes:
          type: array
          items:
            $ref: '#/components/schemas/BulkActivationCancelOutcome'

    BulkActivationRescheduleItem:
      type: object
      description: |
        EXACTLY ONE of `activation_id` / `iccid` is required per item, plus
        at least one of `requested_activation_date` / `note`.
      properties:
        activation_id:
          type: string
          format: uuid
        iccid:
          type: string
          description: |
            Alternative to `activation_id` — for callers (e.g. ICC's
            automation) that hold the ICCID instead. Resolved to its current
            in-flight activation request, agent-scoped to your account.
        requested_activation_date:
          type: string
          format: date
          example: '2026-08-20'
        note:
          type: string
          maxLength: 500

    BulkActivationRescheduleRequest:
      type: object
      required: [items]
      properties:
        items:
          type: array
          minItems: 1
          maxItems: 200
          items:
            $ref: '#/components/schemas/BulkActivationRescheduleItem'

    BulkActivationRescheduleOutcome:
      type: object
      description: Exactly one of `activation_id` / `iccid` is present, matching whichever identifier the item was supplied as.
      properties:
        activation_id:
          type: string
          format: uuid
        iccid:
          type: string
        status:
          type: string
          enum: [updated, error]
        reason:
          type: string
          description: |
            Present when status is "error". One of "not_found" (doesn't
            exist, isn't yours, or — for an ICCID — has no open activation),
            "invalid_date", "not_editable:<status>" (already past
            activation_requested — the same requested-only gate used across
            the activation lifecycle), or "error" (unexpected failure).
          example: 'not_editable:processing'

    BulkActivationRescheduleResult:
      type: object
      properties:
        updated:
          type: integer
          example: 18
        errors:
          type: integer
          example: 2
        outcomes:
          type: array
          items:
            $ref: '#/components/schemas/BulkActivationRescheduleOutcome'

    # ---- eSIMs ----
    EsimStatus:
      type: object
      properties:
        iccid:
          type: string
        status:
          type: string
          enum: [inactive, active, suspended, expired]
        activated_at:
          type: [string, "null"]
          format: date-time
        expires_at:
          type: [string, "null"]
          format: date-time
        data_usage:
          type: object
          properties:
            total_mb:
              type: number
            used_mb:
              type: number
            remaining_mb:
              type: number
            usage_percentage:
              type: number
        current_location:
          type: [object, "null"]
          properties:
            country:
              type: string
            country_code:
              type: string
            network:
              type: string
        product:
          type: object
          properties:
            name:
              type: string
            sku:
              type: string
            validity_days:
              type: integer

    # ---- Inventory ----
    InventoryStatusResponse:
      type: object
      properties:
        products:
          type: array
          items:
            type: object
            properties:
              product_id:
                type: string
                format: uuid
              sku:
                type: string
              name:
                type: string
              in_stock:
                type: boolean
              available:
                type: boolean
              status:
                type: string
                enum: [active, inactive, limited, unavailable]
              message:
                type: [string, "null"]
              estimated_restock_date:
                type: [string, "null"]
                format: date-time
        count:
          type: integer
        last_updated:
          type: string
          format: date-time

    # ---- Webhooks ----
    CreateWebhookRequest:
      type: object
      required: [url, events]
      properties:
        url:
          type: string
          format: uri
          maxLength: 500
        events:
          type: array
          minItems: 1
          maxItems: 10
          items:
            type: string
            enum:
              - order.created
              - order.fulfilled
              - order.cancelled
              - esim.activated
              - esim.expired
              - payment.completed
              - payment.failed
              - activation.submitted
              - activation.confirmed
              - activation.activated
              - activation.failed
        environment:
          type: string
          enum: [sandbox, production]
        description:
          type: string
          maxLength: 500
        custom_headers:
          type: object
          additionalProperties:
            type: string

    Webhook:
      type: object
      properties:
        id:
          type: string
          format: uuid
        partner_id:
          type: string
          format: uuid
        url:
          type: string
        events:
          type: array
          items:
            type: string
        status:
          type: string
          enum: [active, disabled, failed]
        environment:
          type: string
          enum: [sandbox, production]
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        last_triggered_at:
          type: [string, "null"]
          format: date-time
        success_count:
          type: integer
        failure_count:
          type: integer
        consecutive_failures:
          type: integer

    WebhookDetail:
      allOf:
        - $ref: '#/components/schemas/Webhook'
        - type: object
          properties:
            description:
              type: [string, "null"]
            custom_headers:
              type: object
              additionalProperties:
                type: string

    WebhookWithSecret:
      type: object
      properties:
        webhook:
          $ref: '#/components/schemas/Webhook'
        secret:
          type: string
          description: HMAC signing secret. Shown only once — store it securely.
        message:
          type: string

    # ---- Webhook Payloads ----
    WebhookPayload:
      type: object
      description: |
        All webhook events share this envelope. The `data` field varies by event type.
      properties:
        event:
          type: string
          enum: [order.created, order.fulfilled, order.cancelled, esim.activated, esim.expired, payment.completed, payment.failed, activation.submitted, activation.confirmed, activation.activated, activation.failed]
        event_id:
          type: string
          example: evt_abc123def456
        timestamp:
          type: string
          format: date-time
        data:
          type: object
          description: Event-specific data (see examples below)
      example:
        event: order.fulfilled
        event_id: evt_def456
        timestamp: '2026-04-13T10:05:00Z'
        data:
          id: 550e8400-e29b-41d4-a716-446655440000
          order_number: ORD-20260413-A1B2C
          status: fulfilled
          esims:
            - iccid: '8901234567890123456'
              activation_code: 'LPA:1$smdp.example.com$MATCHING_ID'
              qr_code_url: 'https://esim-hub.vercel.app/api/v1/esims/8901234567890123456/qr'

    # ---- Catalog ----
    CatalogSyncResponse:
      type: object
      properties:
        products:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              sku:
                type: string
              title:
                type: string
              description:
                type: string
                description: HTML formatted, varies by platform
              short_description:
                type: string
              wholesale_price_cents:
                type: integer
              suggested_retail_price_cents:
                type: integer
              currency:
                type: string
              images:
                type: array
                items:
                  type: object
                  properties:
                    url:
                      type: string
                    alt:
                      type: string
                    position:
                      type: integer
              attributes:
                type: object
                properties:
                  data_amount:
                    type: string
                  validity:
                    type: string
                  coverage:
                    type: string
                  type:
                    type: string
                  network:
                    type: string
              categories:
                type: array
                items:
                  type: string
              tags:
                type: array
                items:
                  type: string
              status:
                type: string
              inventory_managed:
                type: boolean
        count:
          type: integer
        format:
          type: string
        metadata:
          type: object
          properties:
            currency:
              type: string
            suggested_markup_percentage:
              type: number
            last_sync:
              type: string
              format: date-time

security:
  - bearerAuth: []
