openapi: 3.1.0
info:
  title: Citrus Mobile Reseller API
  version: '2.0'
  summary: Build your own eSIM platform with pay-as-you-go connectivity in 200+ countries.
  description: |
    The Citrus Mobile Reseller API lets you provision, manage, and bill for eSIMs
    programmatically. Build a travel eSIM storefront, IoT connectivity platform,
    corporate travel solution, or any product that needs cellular data in 200+
    countries.

    ## Quick Start

    1. Create a developer account at [citrusmobile.com/developer](https://citrusmobile.com/developer).
    2. Top up your reseller balance via the developer dashboard. Minimum **$10**.
    3. Check available rates with `GET /rates`.
    4. Provision your first eSIM with `POST /esim/provision`.
    5. Fund the eSIM with `POST /esim/{iccid}/fund` — the SIM starts with a $0 wallet.

    ## Pricing

    - **No monthly fee** — API access is free for all registered developers
    - **$1.75 per eSIM** provisioning fee
    - Data rates are **10% below** the retail rates shown on citrusmobile.com
    - Volume-commitment discounts start at $1,000/month — contact sales

    ## Authentication

    All requests require an API key in the `Authorization` header. API keys use
    the prefix `rsk_` and are 68 characters long.

    ```
    Authorization: Bearer rsk_a1b2c3d4e5f6...
    ```

    ### Access levels

    | Operation | API Key | Balance |
    |-----------|---------|---------|
    | Read endpoints (list, status, rates, balance) | Required | — |
    | Provision eSIM | Required | ≥ $1.75 |
    | Enable data | Required | > $0 |
    | Throttle speed | Required | — |
    | Fund eSIM | Required | ≥ funded amount |
    | Defund eSIM (return unused balance) | Required | — |
    | Disable / Terminate | Required | — |
    | Webhook management | Required | — |
    | Create / update group | Required | — |
    | Fund group | Required | ≥ funded amount |
    | Defund group | Required | — |
    | Add / remove group eSIM | Required | — |

    ## Rate Limits

    API requests are limited to **100 requests per minute** per API key.
    Exceeding this returns `429 Too Many Requests` with a `Retry-After` header.
    Need higher limits? Email [support@citrusmobile.com](mailto:support@citrusmobile.com).

    ## Display Precision

    `wallet_balance_usd` is always rounded **down** to the nearest cent and may
    be up to ~5¢ below the exact remaining credit — never above it. Funding
    `$20` will show `$19.99` right after funding; funding `$9`, `$18`, or `$27`
    round-trips exactly. Your customer is never shortchanged — the SIM always
    has at least the displayed amount of credit.

    ## Propagation Delay

    Our upstream network takes about 10 minutes to reflect credit and usage
    changes. `POST /fund` returns immediately and your customer can start using
    data right away, but the reporting pipeline (`total_data_charged_usd`
    updates, `esim.balance_low` / `esim.balance_depleted` webhooks) pauses for
    about 15 minutes after every fund so upstream propagation can settle. Usage
    during that window is still counted and appears on the next sync cycle.

    ## Shared Balance Groups

    A group is a shared wallet. Every eSIM you put in it draws from one balance
    instead of being funded one by one. A partner running 20 eSIMs for a single
    customer funds one group, not 20 wallets.

    ### One-time setup, per group

    1. `POST /groups` to create it. Set `low_balance_threshold_usd` and
       `refill_amount_usd` here, or accept the defaults of $10 and $25.
    2. `POST /groups/{id}/fund` to move money from your account into the group.

    ### Per eSIM

    3. `POST /esim/provision` with `group_id`. The eSIM is born into the group.
       It has no wallet of its own, so `POST /esim/{iccid}/fund` on it returns
       `ESIM_IN_GROUP`. Fund the group instead.

    ### The loop, from then on

    4. The group balance drops below `low_balance_threshold_usd`.
    5. If your account balance cannot cover `refill_amount_usd` and you have
       auto-refill configured, we charge your saved card first and wait for the
       credit to land. This is why the notification in step 6 can arrive a few
       minutes after the balance actually dipped: we would rather you receive it
       with funds already in place than send you a transfer that fails.
    6. We send the `group.balance_low` webhook.
    7. Your system calls `POST /groups/{id}/fund` for `suggested_amount_usd`.

    ### Drive the loop from `group.balance_low`, not from a top-up event

    Step 7 must be triggered by `group.balance_low`, which fires once per
    crossing and only when the group actually needs money.

    Triggering the same transfer on `balance.topped_up` or
    `balance.auto_refill_succeeded` looks equivalent and is not. Those fire
    whenever your account is credited, including by your own auto-refill, so the
    transfer runs against money that has just arrived and empties the account
    again, which asks for another refill.

    Two guards make this bounded rather than unbounded, and neither is
    permission to build it that way:

    - Moving money into a group or a SIM wallet is a transfer, not spend, and
      never triggers an auto-refill charge. Only real spend does: provisioning
      an eSIM, or a group that is low while your account cannot cover it.
    - Auto-refill stops after 10 charges in 24 hours, switches itself off, and
      emails you.

    If you do fund on a top-up event, read the group's current `balance_usd`
    first and transfer only the shortfall against its threshold.

    ### Subscribe your webhook to the group events first

    **If you registered your webhook before shared balance groups existed, you
    are not subscribed to the group events and step 6 will never reach you.**
    Webhook subscriptions are an explicit list of event names, so the four
    `group.*` events and the two `esim.group_*` events are not added to existing
    endpoints automatically.

    Check what you are subscribed to with `GET /webhooks`, then update the
    endpoint with `PUT /webhooks/{id}`:

    ```
    PUT /webhooks/{id}
    { "events": ["*"] }
    ```

    Using `["*"]` subscribes to every current and future event. If you prefer an
    explicit list, add at least `group.balance_low`, since the whole top-up loop
    depends on it, plus `group.funded`, `group.balance_depleted`,
    `group.defunded`, `esim.group_assigned`, and `esim.group_removed`.
    `GET /webhooks` returns the full set in `available_events`.

    `group.balance_low` fires **once per crossing**, not once per sync cycle. A
    group sitting below its threshold will not re-notify until the balance
    recovers above the threshold and dips again. Funding the group also resets
    it, so a partial top-up that leaves the group still low will re-notify on
    the next dip.

    If we could not top up your account within about 30 minutes, the webhook is
    sent anyway with `account_underfunded: true`. That flag means your saved
    card needs attention: check `GET /account` for `auto_refill_last_error`.

    ### Moving eSIMs between modes

    eSIMs move freely. `POST /groups/{id}/esims` adds one, and
    `DELETE /groups/{id}/esims/{iccid}` returns it to standalone mode at a $0
    balance.

    Most moves complete immediately and return `200`. The one exception is
    moving a standalone eSIM that still holds its own balance. We cannot simply
    flip it, or that money would be stranded, so we pause its data, return the
    balance to your account, and complete the move once our upstream reporting
    settles. That returns `202` with `status: "move_pending"` and
    `settles_in_minutes`. A `202` is not a failure. Watch for the
    `esim.group_assigned` webhook, or poll `GET /esim/{iccid}` until `group_id`
    is set.

    ### Getting money back out

    `POST /groups/{id}/defund` returns a group's remaining balance to your
    account. It pauses data on every member eSIM first, then settles after about
    15 minutes. Members stay in the group at a $0 balance and resume as soon as
    you fund the group again.

    ### Limits

    You can have up to **10 groups**, each holding up to **500 eSIMs**. These
    limits exist only to deter spam and abuse. If your business needs more,
    email [support@citrusmobile.com](mailto:support@citrusmobile.com) and we
    will raise them.

    Groups cannot be deleted. To reuse one, move its eSIMs out, defund it, and
    rename it with `PATCH /groups/{id}`.
  contact:
    email: support@citrusmobile.com
    url: https://citrusmobile.com/developer

servers:
  - url: https://citrusmobile.com/api/v2/reseller
    description: Production

security:
  - ApiKeyAuth: []

tags:
  - name: eSIMs
    description: Provision and manage individual eSIM profiles.
  - name: Groups
    description: |
      Shared-balance groups. Create a group, move funds into it, and issue eSIMs
      into it. Every member eSIM draws from the one group balance instead of
      being funded individually.
  - name: Wallet
    description: Check your reseller account balance and usage history.
  - name: Rates
    description: Per-country data rates charged to your account.
  - name: Account
    description: Account details and eSIM counts.
  - name: Webhooks
    description: Manage webhook endpoints for real-time event notifications.

paths:
  /esim/provision:
    post:
      tags: [eSIMs]
      summary: Provision a new eSIM
      description: |
        Reserves an eSIM from inventory, assigns it to your billing group, and
        returns installation details including a QR code (base64 PNG data URL)
        and an iOS direct install URL.

        The SIM is created with a **$0 wallet** — call `POST /esim/{iccid}/fund`
        before your customer can use data. Delivery of the QR code or install
        URL is your responsibility (e.g. via email).

        Pass `group_id` to issue the eSIM into a shared-balance group instead.
        It then draws from the group balance and is not funded individually.
      operationId: provisionEsim
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                end_user_reference:
                  type: string
                  description: Your own customer/order ID for tracking. Surfaced in webhook payloads.
                  example: order_789
                label:
                  type: string
                  description: Human-friendly label shown in dashboards and webhook payloads.
                  example: Jane Smith - Tokyo
                group_id:
                  type: string
                  format: uuid
                  description: |
                    Issue this eSIM into a shared-balance group. It will draw
                    from the group balance and cannot be funded individually.
                    Omit for a standalone eSIM funded with
                    `POST /esim/{iccid}/fund`.
      responses:
        '201':
          description: eSIM created and reserved.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProvisionedEsim'
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '402': { $ref: '#/components/responses/InsufficientBalance' }
        '503': { $ref: '#/components/responses/NoEsimsAvailable' }

  /esim/list:
    get:
      tags: [eSIMs]
      summary: List your eSIMs
      description: |
        List all eSIMs provisioned by your account with pagination and optional
        status filtering. The returned items include the per-SIM
        `wallet_balance_usd` so you can drive a dashboard without a follow-up
        lookup.
      operationId: listEsims
      parameters:
        - name: status
          in: query
          description: Filter by status
          schema:
            type: string
            enum: [pending, active, suspended, terminated]
        - name: limit
          in: query
          description: Max results per page (up to 100).
          schema:
            type: integer
            default: 50
            maximum: 100
        - name: offset
          in: query
          description: Pagination offset.
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: eSIM list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  esims:
                    type: array
                    items:
                      $ref: '#/components/schemas/EsimListItem'
                  total:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
                example:
                  esims:
                    - id: 550e8400-e29b-41d4-a716-446655440000
                      iccid: '8999201200000000001'
                      status: active
                      euicc_state: ENABLED
                      end_user_reference: order_789
                      label: Jane Smith - Tokyo
                      wallet_balance_usd: 14.40
                      total_data_charged_usd: 3.60
                      activated_at: '2026-04-07T12:00:00.000Z'
                      terminated_at: null
                      created_at: '2026-04-07T10:30:00.000Z'
                  total: 1
                  limit: 50
                  offset: 0
        '401': { $ref: '#/components/responses/InvalidApiKey' }

  /esim/{iccid}:
    get:
      tags: [eSIMs]
      summary: Get eSIM details
      description: |
        Get full detail for one eSIM, including a live activation state read
        from the network (with graceful fallback to the cached state).

        **`wallet_balance_usd`** is the remaining balance on this SIM's wallet
        in the same units you funded it with. When it hits 0, data stops. See
        the display-precision note in the intro.

        **`total_data_charged_usd`** is the lifetime data usage charged for
        this SIM at the rates returned by `/rates`. Use it as a running meter
        for per-customer throttle or quota logic.
      operationId: getEsim
      parameters:
        - $ref: '#/components/parameters/IccidPath'
      responses:
        '200':
          description: eSIM detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EsimDetail'
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '404': { $ref: '#/components/responses/EsimNotFound' }

  /esim/{iccid}/enable:
    post:
      tags: [eSIMs]
      summary: Enable data on an eSIM
      description: Enable cellular data. Use this to resume connectivity after a disable or suspension.
      operationId: enableEsim
      parameters:
        - $ref: '#/components/parameters/IccidPath'
      responses:
        '200':
          description: Data enabled.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  iccid: { type: string }
                  data_enabled: { type: boolean }
                example:
                  success: true
                  iccid: '8999201200000000001'
                  data_enabled: true
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '402': { $ref: '#/components/responses/InsufficientBalance' }
        '404': { $ref: '#/components/responses/EsimNotFound' }

  /esim/{iccid}/disable:
    post:
      tags: [eSIMs]
      summary: Disable data on an eSIM
      description: Disable cellular data. The eSIM remains provisioned but cannot pass data until re-enabled.
      operationId: disableEsim
      parameters:
        - $ref: '#/components/parameters/IccidPath'
      responses:
        '200':
          description: Data disabled.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  iccid: { type: string }
                  data_enabled: { type: boolean }
                example:
                  success: true
                  iccid: '8999201200000000001'
                  data_enabled: false
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '404': { $ref: '#/components/responses/EsimNotFound' }

  /esim/{iccid}/terminate:
    post:
      tags: [eSIMs]
      summary: Terminate an eSIM
      description: |
        Permanently terminate an eSIM. Disables data and marks the profile as
        terminated. **This action cannot be undone.** Any remaining wallet
        balance is forfeited.
      operationId: terminateEsim
      parameters:
        - $ref: '#/components/parameters/IccidPath'
      responses:
        '200':
          description: eSIM terminated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  iccid: { type: string }
                  status: { type: string, enum: [terminated] }
                example:
                  success: true
                  iccid: '8999201200000000001'
                  status: terminated
        '400': { $ref: '#/components/responses/EsimAlreadyTerminated' }
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '404': { $ref: '#/components/responses/EsimNotFound' }

  /esim/{iccid}/throttle:
    post:
      tags: [eSIMs]
      summary: Throttle SIM speed
      description: |
        Set the data speed on an eSIM. Use this to implement fair-use policies
        (e.g., full speed for the first 2 GB/day, then throttle to 512 Kbps).
        Set to `NO_LIMIT` to restore full speed.

        Valid speeds: `NO_LIMIT`, `SPEED_100_KBPS` through `SPEED_3000_KBPS`
        in 100 Kbps increments, plus `SPEED_3500_KBPS`, `SPEED_4000_KBPS`,
        `SPEED_4500_KBPS`, and `SPEED_5000_KBPS`.

        Common choices:
        - `SPEED_500_KBPS` — basic browsing and messaging
        - `SPEED_1000_KBPS` — standard definition video
        - `SPEED_2000_KBPS` — light HD streaming
      operationId: throttleEsim
      parameters:
        - $ref: '#/components/parameters/IccidPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [speed]
              properties:
                speed:
                  type: string
                  enum:
                    - NO_LIMIT
                    - SPEED_100_KBPS
                    - SPEED_500_KBPS
                    - SPEED_1000_KBPS
                    - SPEED_2000_KBPS
                    - SPEED_5000_KBPS
                  description: Throttle speed. See the full list in the description.
            example:
              speed: SPEED_500_KBPS
      responses:
        '200':
          description: Throttle applied.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  iccid: { type: string }
                  throttle: { type: string }
                example:
                  success: true
                  iccid: '8999201200000000001'
                  throttle: SPEED_500_KBPS
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '404': { $ref: '#/components/responses/EsimNotFound' }

  /esim/{iccid}/defund:
    post:
      tags: [eSIMs]
      summary: Return an eSIM's unused balance
      operationId: defundEsim
      x-badges:
        - name: New
          position: after
          color: green
      description: >
        Returns the unused balance on an eSIM to your account balance. This is
        asynchronous. The call immediately pauses data on the SIM and returns
        `202` with an estimate. The credit lands after a short settlement period
        (about 15 minutes), once upstream usage has fully reported.


        The final amount credited may be lower than `estimated_return_usd` if
        your customer used data between your request and settlement. The returned
        amount equals the SIM's displayed balance. The SIM is not terminated: its
        profile stays installed and you can fund it again later. A SIM with a
        return in progress cannot be funded or re-enabled until it completes.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: iccid
          in: path
          required: true
          schema: { type: string }
      responses:
        '202':
          description: Balance return accepted and data paused.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  iccid: { type: string, example: '8999201200000000001' }
                  status: { type: string, example: defund_pending }
                  estimated_return_usd: { type: number, example: 12.40 }
                  data_disabled: { type: boolean, example: true }
                  settles_in_minutes: { type: integer, example: 15 }
        '400':
          description: >
            No returnable balance (`NO_BALANCE_TO_RETURN`), the SIM is
            terminated (`ESIM_ALREADY_TERMINATED`), or it has no wallet yet
            (`WALLET_NOT_READY`).
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '404': { $ref: '#/components/responses/EsimNotFound' }
        '409':
          description: A balance return is already in progress (`DEFUND_ALREADY_PENDING`).
        '502':
          description: Could not pause data upstream; nothing was moved (`UPSTREAM_ERROR`).

  /esim/{iccid}/fund:
    post:
      tags: [eSIMs]
      summary: Fund an eSIM wallet
      description: |
        Fund an eSIM's data wallet. Deducts from your reseller account balance
        and credits the SIM's individual wallet. The eSIM can use data until
        this funded amount is consumed at the rates shown in `/rates`.

        Your reseller account is **not** debited again during usage — the fund
        call is the only charge. To top a customer up mid-trip, just call
        this endpoint again with the additional amount.

        See the **Display Precision** and **Propagation Delay** notes in the
        intro for details on how the balance number you get back relates to
        the amount you funded.
      operationId: fundEsim
      parameters:
        - $ref: '#/components/parameters/IccidPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount:
                  type: number
                  minimum: 0.01
                  maximum: 10000
                  description: Amount in USD to fund this eSIM (deducted from your reseller account balance).
            example:
              amount: 18
      responses:
        '200':
          description: eSIM funded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  iccid: { type: string }
                  funded_usd: { type: number }
                  wallet_balance_usd: { type: number }
                  account_balance_remaining: { type: number }
                example:
                  success: true
                  iccid: '8999201200000000001'
                  funded_usd: 18.00
                  wallet_balance_usd: 18.00
                  account_balance_remaining: 80.25
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '402': { $ref: '#/components/responses/InsufficientBalance' }
        '404': { $ref: '#/components/responses/EsimNotFound' }

  /groups:
    get:
      tags: [Groups]
      summary: List groups (New)
      x-badges:
        - name: New
          position: after
          color: green
      description: |
        Every shared-balance group on your account, newest first.

        Remove the "New" badge from this operation once the feature is no longer
        newly launched.
      operationId: listGroups
      responses:
        '200':
          description: Groups
          content:
            application/json:
              schema:
                type: object
                properties:
                  groups:
                    type: array
                    items: { $ref: '#/components/schemas/Group' }
                  total: { type: integer, example: 2 }
        '401': { $ref: '#/components/responses/InvalidApiKey' }
    post:
      tags: [Groups]
      summary: Create a group (New)
      x-badges:
        - name: New
          position: after
          color: green
      description: |
        Creates a shared-balance group with a $0 balance. Fund it with
        `POST /groups/{id}/fund` before issuing eSIMs into it.

        Groups cannot be deleted, and you can have at most 10. This limit exists
        only to deter spam and abuse; email support@citrusmobile.com if you need
        more.
      operationId: createGroup
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  maxLength: 100
                  description: Your label for the group. Must be unique on your account.
                  example: Acme Corp Fleet
                low_balance_threshold_usd:
                  type: number
                  default: 10
                  description: Send `group.balance_low` when the group balance drops below this.
                  example: 25
                refill_amount_usd:
                  type: number
                  default: 25
                  description: How much you intend to transfer in when notified. Sent as `suggested_amount_usd`.
                  example: 100
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Group' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '409':
          description: A group with that name already exists (`GROUP_NAME_TAKEN`)
        '429':
          description: Group limit reached (`GROUP_LIMIT_REACHED`)

  /groups/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Groups]
      summary: Get a group (New)
      x-badges:
        - name: New
          position: after
          color: green
      operationId: getGroup
      responses:
        '200':
          description: Group
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Group' }
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '404':
          description: Not found (`GROUP_NOT_FOUND`)
    patch:
      tags: [Groups]
      summary: Update a group (New)
      x-badges:
        - name: New
          position: after
          color: green
      description: |
        Change the name or the top-up policy. Provide at least one field.

        Renaming is how you reuse a group you are done with, since groups cannot
        be deleted.
      operationId: updateGroup
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 100 }
                low_balance_threshold_usd: { type: number }
                refill_amount_usd: { type: number }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Group' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '404':
          description: Not found (`GROUP_NOT_FOUND`)
        '409':
          description: Name already taken (`GROUP_NAME_TAKEN`)

  /groups/{id}/fund:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Groups]
      summary: Move funds into a group (New)
      x-badges:
        - name: New
          position: after
          color: green
      description: |
        Debits your account balance and credits the group. Every member eSIM
        draws from this balance.

        Funding also resets the low-balance notification state, so the group
        will notify again on its next dip below the threshold.
      operationId: fundGroup
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount:
                  type: number
                  minimum: 0.01
                  maximum: 10000
                  example: 100
      responses:
        '200':
          description: Funded
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  group_id: { type: string, format: uuid }
                  funded_usd: { type: number, example: 100 }
                  balance_usd: { type: number, example: 99.99 }
                  account_balance_remaining: { type: number, example: 400 }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '402':
          description: Account balance too low (`INSUFFICIENT_BALANCE`)
        '404':
          description: Not found (`GROUP_NOT_FOUND`)
        '409':
          description: A balance return is in progress (`GROUP_DEFUND_PENDING`)

  /groups/{id}/defund:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Groups]
      summary: Return a group balance to your account (New)
      x-badges:
        - name: New
          position: after
          color: green
      description: |
        Pauses data on every member eSIM, then returns the group's remaining
        balance to your account. Settlement takes about 15 minutes because our
        upstream network needs that long to report final usage.

        Members stay in the group at a $0 balance and resume as soon as you fund
        it again. Watch for the `group.defunded` webhook.
      operationId: defundGroup
      responses:
        '200':
          description: Return requested
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  group_id: { type: string, format: uuid }
                  status: { type: string, example: defund_pending }
                  estimated_return_usd: { type: number, example: 42.5 }
                  esims_paused: { type: integer, example: 20 }
                  settles_in_minutes: { type: integer, example: 15 }
        '400':
          description: Nothing to return (`NO_BALANCE_TO_RETURN`)
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '404':
          description: Not found (`GROUP_NOT_FOUND`)
        '409':
          description: Already in progress (`GROUP_DEFUND_PENDING`)

  /groups/{id}/esims:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Groups]
      summary: List eSIMs in a group (New)
      x-badges:
        - name: New
          position: after
          color: green
      operationId: listGroupEsims
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 50, maximum: 100 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
      responses:
        '200':
          description: Members
          content:
            application/json:
              schema:
                type: object
                properties:
                  group_id: { type: string, format: uuid }
                  name: { type: string }
                  esims:
                    type: array
                    items:
                      type: object
                      properties:
                        iccid: { type: string }
                        status: { type: string }
                        euicc_state: { type: string }
                        end_user_reference: { type: string, nullable: true }
                        label: { type: string, nullable: true }
                        activated_at: { type: string, nullable: true }
                        created_at: { type: string }
                  total: { type: integer }
                  limit: { type: integer }
                  offset: { type: integer }
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '404':
          description: Not found (`GROUP_NOT_FOUND`)
    post:
      tags: [Groups]
      summary: Add an eSIM to a group (New)
      x-badges:
        - name: New
          position: after
          color: green
      description: |
        Moves an existing eSIM into this group.

        Returns `200` immediately in most cases. If the eSIM is standalone and
        still holds its own balance, we cannot flip it without stranding that
        money, so we pause its data, return the balance to your account, and
        finish the move once our upstream reporting settles. That case returns
        `202` with `status: "move_pending"`. Watch for `esim.group_assigned`.

        A group holds at most 500 eSIMs. This limit exists only to deter spam
        and abuse; email support@citrusmobile.com if you need more.
      operationId: addEsimToGroup
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [iccid]
              properties:
                iccid: { type: string, example: '8944478000000123456' }
      responses:
        '200':
          description: Moved
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  iccid: { type: string }
                  group_id: { type: string, format: uuid }
                  status: { type: string, example: assigned }
        '202':
          description: Move accepted, settling
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  iccid: { type: string }
                  group_id: { type: string, format: uuid }
                  status: { type: string, example: move_pending }
                  settles_in_minutes: { type: integer, example: 15 }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '404':
          description: Group or eSIM not found (`GROUP_NOT_FOUND`, `ESIM_NOT_FOUND`)
        '409':
          description: Already a member, or a move is already in progress (`ESIM_ALREADY_IN_GROUP`, `MOVE_PENDING`)
        '429':
          description: Group is full (`GROUP_FULL`)

  /groups/{id}/esims/{iccid}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
      - name: iccid
        in: path
        required: true
        schema: { type: string }
    delete:
      tags: [Groups]
      summary: Remove an eSIM from a group (New)
      x-badges:
        - name: New
          position: after
          color: green
      description: |
        Returns the eSIM to standalone mode with a $0 balance. Fund it with
        `POST /esim/{iccid}/fund`. No money moves, because a pooled eSIM holds
        none of its own.
      operationId: removeEsimFromGroup
      responses:
        '200':
          description: Removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  iccid: { type: string }
                  group_id: { type: string, nullable: true, example: null }
                  status: { type: string, example: standalone }
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '404':
          description: Not a member of that group (`ESIM_NOT_FOUND`)

  /wallet/balance:
    get:
      tags: [Wallet]
      summary: Get reseller account balance
      description: |
        Check your current reseller account balance and lifetime spending
        statistics.

        `data_suspended` is `true` **only** if Citrus support has manually
        frozen your account. A zero main-account balance does **not** disable
        existing eSIMs — they keep running as long as their per-SIM wallets
        have credit. Use the per-SIM `esim.balance_depleted` webhook to
        detect individual SIM cutoffs.

        `lifetime_usage_usd` is the total you've spent out of your reseller
        account (provisioning fees + every `/fund` call). It doesn't include
        pending top-ups.
      operationId: getWalletBalance
      responses:
        '200':
          description: Wallet balance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WalletBalance'
        '401': { $ref: '#/components/responses/InvalidApiKey' }

  /rates:
    get:
      tags: [Rates]
      summary: Get per-country data rates
      description: |
        Get per-country data rates — 10% below the retail rates on
        citrusmobile.com. These are the rates charged to your account as
        your customers consume data. Filter by country or continent.
      operationId: getRates
      parameters:
        - name: country
          in: query
          description: Filter by ISO2 code, slug, or name (e.g. `US`, `japan`).
          schema: { type: string }
        - name: continent
          in: query
          description: Filter by continent (e.g. `Europe`, `Asia`).
          schema: { type: string }
      responses:
        '200':
          description: Rate card.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RatesResponse'
              example:
                total_countries: 1
                discount: '10% off retail rates'
                countries:
                  - name: Japan
                    iso2: JP
                    iso3: JPN
                    flag: 🇯🇵
                    continent: Asia
                    has_data: true
                    cheapest_per_gb_usd: 1.38
                    cheapest_operator: KDDI Corporation
                    networks:
                      - { operator: KDDI Corporation, per_mb_usd: 0.0013, per_gb_usd: 1.38 }
                      - { operator: Rakuten, per_mb_usd: 0.0016, per_gb_usd: 1.70 }
                      - { operator: DOCOMO, per_mb_usd: 0.0022, per_gb_usd: 2.30 }
                      - { operator: SoftBank, per_mb_usd: 0.0028, per_gb_usd: 2.95 }
        '401': { $ref: '#/components/responses/InvalidApiKey' }

  /account:
    get:
      tags: [Account]
      summary: Get account details
      description: Returns your account details and eSIM counts.
      operationId: getAccount
      responses:
        '200':
          description: Account detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Account'
              example:
                company_name: TravelConnect Inc.
                contact_email: api@travelconnect.com
                balance_usd: 142.50
                esim_count: { active: 47, total: 123 }
                account_status: active
                created_at: '2026-03-15T09:00:00.000Z'
        '401': { $ref: '#/components/responses/InvalidApiKey' }

  /webhooks:
    get:
      tags: [Webhooks]
      summary: List webhook endpoints
      description: List all registered webhook endpoints for your account. Also returns the full set of subscribable event names.
      operationId: listWebhooks
      responses:
        '200':
          description: Webhook list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhooks:
                    type: array
                    items:
                      $ref: '#/components/schemas/Webhook'
                  available_events:
                    type: array
                    items: { type: string }
        '401': { $ref: '#/components/responses/InvalidApiKey' }
    post:
      tags: [Webhooks]
      summary: Register a new webhook endpoint
      description: |
        Register a new webhook endpoint. You'll receive a `signing_secret` in
        the response — **save it immediately as it won't be shown again**.
        Maximum 5 webhooks per account.

        Subscribe to specific events or pass `["*"]` to receive all events.
      operationId: createWebhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url:
                  type: string
                  format: uri
                  description: HTTPS URL that receives webhook POSTs.
                events:
                  type: array
                  items: { type: string }
                  description: Events to subscribe to, or `["*"]` for all.
            example:
              url: 'https://your-server.com/webhooks/citrus'
              events: [esim.activated, balance.low, balance.depleted]
      responses:
        '201':
          description: Webhook registered.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookWithSecret'
              example:
                id: 'wh_550e8400-...'
                url: 'https://your-server.com/webhooks/citrus'
                events: [esim.activated, balance.low, balance.depleted]
                active: true
                signing_secret: 'whsec_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6'
                note: Save the signing_secret — it will not be shown again.
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/InvalidApiKey' }

  /webhooks/{id}:
    get:
      tags: [Webhooks]
      summary: Get webhook detail
      operationId: getWebhook
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Webhook detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Webhook'
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      tags: [Webhooks]
      summary: Update a webhook
      description: Update URL, events, or active state. Setting `active` to `true` resets `failure_count` to 0.
      operationId: updateWebhook
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, format: uri }
                events:
                  type: array
                  items: { type: string }
                active: { type: boolean }
            example:
              active: true
      responses:
        '200':
          description: Webhook updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Webhook'
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Webhooks]
      summary: Delete a webhook
      operationId: deleteWebhook
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                example:
                  success: true
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '404': { $ref: '#/components/responses/NotFound' }

  /webhooks/{id}/test:
    post:
      tags: [Webhooks]
      summary: Send a test event
      description: |
        Send a test event to a webhook endpoint to verify it's receiving and
        processing correctly. The test event uses the special event type
        `"test"` and does not require a subscription to any specific event.
      operationId: testWebhook
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Test delivery result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  status: { type: integer, description: The HTTP status code returned by your endpoint. }
                example:
                  success: true
                  status: 200
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '404': { $ref: '#/components/responses/NotFound' }

webhooks:
  esim.provisioned:
    post:
      summary: eSIM provisioned
      description: Fires after a new eSIM is successfully reserved and assigned to your account.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEnvelope'
            example:
              id: 'evt_a1b2c3d4e5f6'
              event: esim.provisioned
              created_at: '2026-04-07T10:30:00.000Z'
              data:
                iccid: '8999201200000000001'
                lpa_string: 'LPA:1$sm-dp-plus.example.com$ACTIVATION-CODE-EXAMPLE'
                end_user_reference: order_789
                label: Jane Smith - Tokyo
                status: pending
                created_at: '2026-04-07T10:30:00.000Z'
      responses:
        '200':
          description: Your server acknowledges receipt.

  esim.activated:
    post:
      summary: eSIM activated on a device
      description: Fires when a customer installs and boots the eSIM profile for the first time.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEnvelope'
            example:
              id: 'evt_...'
              event: esim.activated
              created_at: '2026-04-07T12:00:00.000Z'
              data:
                iccid: '8999201200000000001'
                activated_at: '2026-04-07T12:00:00.000Z'
                euicc_state: ENABLED
      responses:
        '200':
          description: Acknowledged.

  esim.terminated:
    post:
      summary: eSIM terminated
      description: Fires after a successful `POST /esim/{iccid}/terminate` call.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEnvelope'
      responses:
        '200':
          description: Acknowledged.

  esim.defunded:
    post:
      summary: eSIM balance returned
      x-badges:
        - name: New
          position: after
          color: green
      description: >
        Fires after an eSIM's unused balance has been returned to your account
        via `POST /esim/{iccid}/defund`. `returned_usd` is the amount credited
        and the SIM's balance is now zero.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEnvelope'
            example:
              id: 'evt_...'
              event: esim.defunded
              created_at: '2026-04-07T15:00:00.000Z'
              data:
                iccid: '8999201200000000001'
                returned_usd: 12.40
                wallet_balance_usd: 0
      responses:
        '200':
          description: Acknowledged.

  esim.data_suspended:
    post:
      summary: eSIM data manually disabled
      description: Fires when data is manually disabled via `POST /esim/{iccid}/disable`.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEnvelope'
      responses:
        '200':
          description: Acknowledged.

  esim.data_resumed:
    post:
      summary: eSIM data re-enabled
      description: Fires when data is re-enabled via `POST /esim/{iccid}/enable`.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEnvelope'
      responses:
        '200':
          description: Acknowledged.

  esim.balance_low:
    post:
      summary: SIM wallet balance low
      description: |
        Fires **once** when an individual SIM's wallet balance crosses below $5.
        Use this to prompt your end customer to top up their SIM before it cuts off.
        Fires only on the transition (high → low), not every sync cycle.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEnvelope'
            example:
              id: 'evt_...'
              event: esim.balance_low
              created_at: '2026-04-07T15:00:00.000Z'
              data:
                iccid: '8999201200000000001'
                wallet_balance_usd: 4.25
                threshold_usd: 5.0
      responses:
        '200':
          description: Acknowledged.

  esim.balance_depleted:
    post:
      summary: SIM wallet depleted
      description: |
        Fires when an individual SIM's wallet hits $0. The upstream network
        will stop passing data for that SIM. Call `/esim/{iccid}/fund` to
        restore connectivity, or `/esim/{iccid}/terminate` to clean up.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEnvelope'
      responses:
        '200':
          description: Acknowledged.

  balance.low:
    post:
      summary: Reseller account balance low
      description: |
        Fires when your **reseller account** top-up balance drops below $5
        (first crossing only). Use this to know when you won't be able to
        fund new SIMs.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEnvelope'
      responses:
        '200':
          description: Acknowledged.

  balance.depleted:
    post:
      summary: Reseller account balance depleted
      description: Fires when your reseller account top-up balance hits $0.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEnvelope'
      responses:
        '200':
          description: Acknowledged.

  balance.topped_up:
    post:
      summary: Top-up payment received
      description: Fires after a top-up payment succeeds — either manual or triggered by auto-refill.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEnvelope'
            example:
              id: 'evt_...'
              event: balance.topped_up
              created_at: '2026-04-07T16:00:00.000Z'
              data:
                amount_usd: 100.00
                new_balance_usd: 242.50
      responses:
        '200':
          description: Acknowledged.

  balance.auto_refill_succeeded:
    post:
      summary: Auto-refill charge succeeded
      description: |
        Fires alongside `balance.topped_up` when an auto-refill charge
        succeeds. The `payment_intent_id` lets you correlate with your
        Stripe records. We also email the account contact on every
        auto-refill charge.

        **Do not fund a group or an eSIM from this event.** It fires when your
        account is credited, so a transfer triggered here empties the account
        again and asks for another refill. Fund groups from
        `group.balance_low` instead.

        Auto-refill only fires on real spend. Moving money into a group or a
        SIM wallet is a transfer and never triggers a charge. Charges are also
        capped at 10 per rolling 24 hours, after which auto-refill switches
        itself off and you receive `balance.auto_refill_failed` with
        `error_code: daily_limit_exceeded`.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEnvelope'
            example:
              id: 'evt_...'
              event: balance.auto_refill_succeeded
              created_at: '2026-04-07T16:00:00.000Z'
              data:
                amount_usd: 100.00
                payment_intent_id: 'pi_3OaBc...'
      responses:
        '200':
          description: Acknowledged.

  balance.auto_refill_failed:
    post:
      summary: Auto-refill charge failed
      description: |
        Fires when an auto-refill charge fails (card declined, requires 3DS,
        expired, etc.), or when the daily safety ceiling is reached with
        `error_code: daily_limit_exceeded`. In both cases **auto-refill has
        been turned off** — the reseller must complete a manual top-up and
        re-enable auto-refill. The account contact is emailed as well.

        `daily_limit_exceeded` means more than 10 auto-refill charges landed in
        24 hours, which nearly always means a webhook handler is funding a
        group or an eSIM in response to a top-up event. Nothing is lost when
        this trips: every charge went into the account balance and is either
        still there or already moved into groups and SIM wallets.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEnvelope'
            example:
              id: 'evt_...'
              event: balance.auto_refill_failed
              created_at: '2026-04-07T16:00:00.000Z'
              data:
                amount_usd: 100.00
                error_code: card_declined
                error_message: Your card was declined.
      responses:
        '200':
          description: Acknowledged.

  group.funded:
    post:
      summary: Group funded (New)
      description: |
        Fires after funds move from your account into a shared-balance group.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEnvelope'
            example:
              id: 'evt_...'
              event: group.funded
              created_at: '2026-04-07T16:00:00.000Z'
              data:
                group_id: 'b3f1c2d4-0000-4000-8000-000000000001'
                name: Acme Corp Fleet
                amount_usd: 100.00
                balance_usd: 99.99
                account_balance_remaining: 400.00
      responses:
        '200':
          description: Acknowledged.

  group.balance_low:
    post:
      summary: Group balance low (New)
      description: |
        Fires when a group's shared balance drops below its
        `low_balance_threshold_usd`. **This is your signal to call
        `POST /groups/{id}/fund`** for `suggested_amount_usd`.

        Fires once per crossing, not once per sync cycle. It will not fire again
        until the balance recovers above the threshold and dips again; funding
        the group also resets it.

        If your account balance could not cover the refill, we charge your saved
        card first and delay this event until the credit lands, so the transfer
        will have funds behind it. `account_underfunded: true` means we could not
        top up in time and your card needs attention: check `GET /account` for
        `auto_refill_last_error`.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEnvelope'
            example:
              id: 'evt_...'
              event: group.balance_low
              created_at: '2026-04-07T16:00:00.000Z'
              data:
                group_id: 'b3f1c2d4-0000-4000-8000-000000000001'
                name: Acme Corp Fleet
                balance_usd: 8.40
                threshold_usd: 25.00
                suggested_amount_usd: 100.00
                account_balance_usd: 400.00
                account_underfunded: false
      responses:
        '200':
          description: Acknowledged.

  group.balance_depleted:
    post:
      summary: Group balance depleted (New)
      description: |
        Fires when a group's shared balance reaches $0. Every eSIM in the group
        has stopped passing data and resumes as soon as you fund the group.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEnvelope'
            example:
              id: 'evt_...'
              event: group.balance_depleted
              created_at: '2026-04-07T16:00:00.000Z'
              data:
                group_id: 'b3f1c2d4-0000-4000-8000-000000000001'
                name: Acme Corp Fleet
                esim_count: 20
      responses:
        '200':
          description: Acknowledged.

  group.defunded:
    post:
      summary: Group balance returned (New)
      description: |
        Fires when a `POST /groups/{id}/defund` settles and the remaining group
        balance has been credited back to your account.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEnvelope'
            example:
              id: 'evt_...'
              event: group.defunded
              created_at: '2026-04-07T16:00:00.000Z'
              data:
                group_id: 'b3f1c2d4-0000-4000-8000-000000000001'
                name: Acme Corp Fleet
                returned_usd: 42.50
                balance_usd: 0
      responses:
        '200':
          description: Acknowledged.

  esim.group_assigned:
    post:
      summary: eSIM added to a group (New)
      description: |
        Fires when an eSIM finishes moving into a shared-balance group. For an
        instant move this arrives right after your API call; for a `202`
        `move_pending` move it arrives once the settle completes, about 15
        minutes later.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEnvelope'
            example:
              id: 'evt_...'
              event: esim.group_assigned
              created_at: '2026-04-07T16:00:00.000Z'
              data:
                iccid: '8999201200000000001'
                group_id: 'b3f1c2d4-0000-4000-8000-000000000001'
                name: Acme Corp Fleet
      responses:
        '200':
          description: Acknowledged.

  esim.group_removed:
    post:
      summary: eSIM removed from a group (New)
      description: |
        Fires when an eSIM leaves a shared-balance group and returns to
        standalone mode with a $0 balance. Fund it with
        `POST /esim/{iccid}/fund`.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEnvelope'
            example:
              id: 'evt_...'
              event: esim.group_removed
              created_at: '2026-04-07T16:00:00.000Z'
              data:
                iccid: '8999201200000000001'
                group_id: 'b3f1c2d4-0000-4000-8000-000000000001'
                name: Acme Corp Fleet
      responses:
        '200':
          description: Acknowledged.

components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: rsk_a1b2c3d4e5f6...
      description: |
        API key passed as a bearer token. Keys use the `rsk_` prefix and are
        68 characters long. Manage keys at [citrusmobile.com/developer](https://citrusmobile.com/developer).

  parameters:
    IccidPath:
      name: iccid
      in: path
      required: true
      description: The ICCID of the eSIM.
      schema: { type: string, example: '8999201200000000001' }

  schemas:
    ProvisionedEsim:
      type: object
      properties:
        id: { type: string, format: uuid }
        iccid: { type: string }
        lpa_string:
          type: string
          description: The LPA activation code, suitable for generating a QR or for manual entry.
        qr_code:
          type: string
          description: Base64-encoded PNG data URL you can drop directly into an `<img src>`.
        direct_install_url:
          type: string
          description: iOS 17.4+ one-tap install link.
        status: { type: string, enum: [pending, active, suspended, terminated] }
        euicc_state: { type: string, description: 'One of RELEASED, DOWNLOADED, INSTALLED, ENABLED.' }
        end_user_reference: { type: string, nullable: true }
        label: { type: string, nullable: true }
        group_id:
          type: string
          format: uuid
          nullable: true
          description: The shared-balance group this eSIM belongs to, or null if standalone.
        cost:
          type: number
          description: Provisioning cost deducted from your reseller balance ($1.75).
        balance_remaining:
          type: number
          description: Your reseller account balance after provisioning.
        created_at: { type: string, format: date-time }

    EsimListItem:
      type: object
      properties:
        id: { type: string, format: uuid }
        iccid: { type: string }
        status: { type: string }
        euicc_state: { type: string, nullable: true }
        end_user_reference: { type: string, nullable: true }
        label: { type: string, nullable: true }
        group_id:
          type: string
          format: uuid
          nullable: true
          description: The shared-balance group this eSIM belongs to, or null if standalone.
        wallet_balance_usd:
          type: number
          nullable: true
          description: |
            Remaining balance on this SIM's wallet (rounded down to the cent).
            `null` for an eSIM in a shared-balance group, which has no wallet of
            its own: read the group's `balance_usd` instead.
        total_data_charged_usd:
          type: number
          description: Lifetime data usage charged for this SIM.
        activated_at: { type: string, format: date-time, nullable: true }
        terminated_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    Group:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string, example: Acme Corp Fleet }
        balance_usd:
          type: number
          description: Shared balance available to every eSIM in the group.
          example: 99.99
        esim_count: { type: integer, example: 20 }
        low_balance_threshold_usd: { type: number, example: 25 }
        refill_amount_usd: { type: number, example: 100 }
        total_data_charged_usd:
          type: number
          description: Lifetime data usage across every eSIM in the group.
          example: 312.4
        defund_pending:
          type: boolean
          description: True while a balance return is settling.
          example: false
        created_at: { type: string, format: date-time }

    EsimDetail:
      allOf:
        - $ref: '#/components/schemas/EsimListItem'
        - type: object
          properties:
            lpa_string: { type: string }

    WalletBalance:
      type: object
      properties:
        balance_usd: { type: number, description: Current reseller account balance. }
        lifetime_topup_usd: { type: number }
        lifetime_usage_usd:
          type: number
          description: Total spent on provisioning fees and `/fund` calls. Does not include pending top-ups.
        data_suspended:
          type: boolean
          description: True only if Citrus support has manually frozen your account. A zero balance does NOT auto-suspend existing eSIMs.
        currency: { type: string, example: USD }
      example:
        balance_usd: 142.50
        lifetime_topup_usd: 500.00
        lifetime_usage_usd: 357.50
        data_suspended: false
        currency: USD

    RatesResponse:
      type: object
      properties:
        total_countries: { type: integer }
        discount: { type: string, example: '10% off retail rates' }
        countries:
          type: array
          items: { $ref: '#/components/schemas/Country' }

    Country:
      type: object
      properties:
        name: { type: string }
        iso2: { type: string }
        iso3: { type: string }
        flag: { type: string }
        continent: { type: string }
        has_data: { type: boolean }
        cheapest_per_gb_usd: { type: number }
        cheapest_operator: { type: string }
        networks:
          type: array
          items: { $ref: '#/components/schemas/NetworkRate' }

    NetworkRate:
      type: object
      properties:
        operator: { type: string }
        per_mb_usd: { type: number }
        per_gb_usd: { type: number }

    Account:
      type: object
      properties:
        company_name: { type: string, nullable: true }
        contact_email: { type: string }
        balance_usd: { type: number }
        esim_count:
          type: object
          properties:
            active: { type: integer }
            total: { type: integer }
        account_status: { type: string }
        created_at: { type: string, format: date-time }

    Webhook:
      type: object
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        events:
          type: array
          items: { type: string }
        active: { type: boolean }
        failure_count:
          type: integer
          description: Consecutive delivery failures. Auto-disables at 10.
        last_triggered_at: { type: string, format: date-time, nullable: true }
        last_failure_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    WebhookWithSecret:
      allOf:
        - $ref: '#/components/schemas/Webhook'
        - type: object
          properties:
            signing_secret:
              type: string
              description: HMAC-SHA256 signing secret. Save this — it is only returned once.
            note: { type: string }

    WebhookEnvelope:
      type: object
      description: |
        Every webhook delivery follows the same envelope shape. Verify the
        signature using HMAC-SHA256 of the raw request body and the
        `signing_secret` from webhook registration, compared against the
        `X-Citrus-Signature` header.
      properties:
        id: { type: string, description: Unique event ID (`evt_...`). }
        event: { type: string, description: The event type (e.g. `esim.provisioned`). }
        created_at: { type: string, format: date-time }
        data:
          type: object
          description: Per-event payload. See individual webhook definitions for the shape.

    Error:
      type: object
      properties:
        error: { type: string }
        code: { type: string }
        message: { type: string }

  responses:
    InvalidApiKey:
      description: API key is missing, invalid, or revoked.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: Invalid API key
            code: INVALID_API_KEY

    InsufficientBalance:
      description: Reseller balance is insufficient for the requested operation.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: Insufficient balance
            code: INSUFFICIENT_BALANCE
            message: Requires $1.75 but your balance is $0.50.

    EsimNotFound:
      description: ICCID not found or doesn't belong to your account.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: eSIM not found
            code: ESIM_NOT_FOUND

    EsimAlreadyTerminated:
      description: eSIM is already terminated. Cannot un-terminate; provision a new one.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: eSIM is already terminated
            code: ESIM_ALREADY_TERMINATED

    NoEsimsAvailable:
      description: eSIM inventory depleted. Try again later or contact support.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: No eSIMs available in inventory. Please try again later.
            code: NO_ESIMS_AVAILABLE

    ValidationError:
      description: Invalid request body or parameters.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: 'Amount must be between $0.01 and $10,000'
            code: VALIDATION_ERROR

    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: Not found
            code: NOT_FOUND

    RateLimited:
      description: Too many requests — 100 req/min per API key.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: Rate limit exceeded
            code: RATE_LIMITED
            message: Too many requests. Please retry after 30 seconds.
