openapi: 3.1.0
info:
  title: Portwarp API
  version: 2.0.0
  description: Manage Portwarp tunnels, devices, operations, and webhooks. A 403 feature_not_available response means the API or requested remote action is not currently available for the account.
servers:
  - url: https://api.portwarp.com/v2
security:
  - bearerAuth: []
tags:
  - {name: Account}
  - {name: Catalog}
  - {name: Tunnels}
  - {name: Devices}
  - {name: Operations}
  - {name: Webhooks}
paths:
  /:
    get: {tags: [Account], summary: Check API availability, operationId: getApiStatus, x-required-scope: 'account:read', responses: {'200': {$ref: '#/components/responses/Success'}}}
  /account:
    get: {tags: [Account], summary: Get the account visible to this key, operationId: getAccount, x-required-scope: 'account:read', responses: {'200': {$ref: '#/components/responses/Success'}}}
  /account/usage:
    get: {tags: [Account], summary: Get account limits and usage, operationId: getAccountUsage, x-required-scope: 'account:read', responses: {'200': {$ref: '#/components/responses/Success'}}}
  /nodes:
    get: {tags: [Catalog], summary: List available nodes, operationId: listNodes, x-required-scope: 'tunnels:read', responses: {'200': {$ref: '#/components/responses/Success'}}}
  /presets:
    get: {tags: [Catalog], summary: List tunnel presets, operationId: listPresets, x-required-scope: 'tunnels:read', responses: {'200': {$ref: '#/components/responses/Success'}}}
  /domains:
    get: {tags: [Catalog], summary: List domains available for tunnels, operationId: listDomains, x-required-scope: 'tunnels:read', responses: {'200': {$ref: '#/components/responses/Success'}}}
  /scope-presets:
    get: {tags: [Catalog], summary: List API key scope presets, operationId: listScopePresets, x-required-scope: 'account:read', responses: {'200': {$ref: '#/components/responses/Success'}}}
  /tunnels:
    get:
      tags: [Tunnels]
      summary: List tunnels
      operationId: listTunnels
      x-required-scope: 'tunnels:read'
      parameters: [{$ref: '#/components/parameters/Cursor'}, {$ref: '#/components/parameters/Limit'}]
      responses: {'200': {$ref: '#/components/responses/TunnelList'}}
    post:
      tags: [Tunnels]
      summary: Create a tunnel
      operationId: createTunnel
      x-required-scope: 'tunnels:write'
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/TunnelCreate'}
            example: {name: Minecraft, protocol: tcp, local_port: 25565, device_ids: []}
      responses:
        '201': {$ref: '#/components/responses/TunnelOne'}
        '409': {$ref: '#/components/responses/Problem'}
        '422': {$ref: '#/components/responses/Problem'}
  /tunnels/{tunnel_id}:
    parameters: [{$ref: '#/components/parameters/TunnelId'}]
    get: {tags: [Tunnels], summary: Get a tunnel, operationId: getTunnel, x-required-scope: 'tunnels:read', responses: {'200': {$ref: '#/components/responses/TunnelOne'}, '404': {$ref: '#/components/responses/Problem'}}}
    patch:
      tags: [Tunnels]
      summary: Update tunnel settings
      operationId: updateTunnel
      x-required-scope: 'tunnels:write'
      requestBody: {$ref: '#/components/requestBodies/TunnelUpdate'}
      responses: {'200': {$ref: '#/components/responses/TunnelOne'}, '422': {$ref: '#/components/responses/Problem'}}
    delete:
      tags: [Tunnels]
      summary: Delete a tunnel
      operationId: deleteTunnel
      x-required-scope: 'tunnels:write'
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      responses: {'200': {$ref: '#/components/responses/Success'}, '404': {$ref: '#/components/responses/Problem'}}
  /tunnels/{tunnel_id}/devices:
    put:
      tags: [Tunnels]
      summary: Replace the allowed device list
      operationId: replaceTunnelDeviceBindings
      description: Send an empty device_ids array to allow any eligible device. A tunnel runs on at most one device at a time.
      x-required-scope: 'tunnels:write'
      parameters: [{$ref: '#/components/parameters/TunnelId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody:
        required: true
        content: {application/json: {schema: {$ref: '#/components/schemas/DeviceBindings'}, example: {device_ids: []}}}
      responses: {'200': {$ref: '#/components/responses/Success'}, '422': {$ref: '#/components/responses/Problem'}}
  /tunnels/{tunnel_id}/actions/start:
    post:
      tags: [Tunnels]
      summary: Start a tunnel on a device
      operationId: startTunnel
      description: Set handoff to true only when you want to stop the current executor and continue on the requested device.
      x-required-scope: 'tunnels:control'
      parameters:
        - {$ref: '#/components/parameters/TunnelId'}
        - {$ref: '#/components/parameters/IdempotencyKey'}
      requestBody:
        required: true
        content: {application/json: {schema: {$ref: '#/components/schemas/TunnelStartAction'}, example: {device_id: 8dfda098-6da3-4ea4-b8af-85f62d634b86, handoff: false}}}
      responses: {'202': {$ref: '#/components/responses/OperationOne'}, '409': {$ref: '#/components/responses/Problem'}}
  /tunnels/{tunnel_id}/actions/stop:
    post:
      tags: [Tunnels]
      summary: Stop the running tunnel
      operationId: stopTunnel
      x-required-scope: 'tunnels:control'
      parameters: [{$ref: '#/components/parameters/TunnelId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody:
        content: {application/json: {schema: {type: object, maxProperties: 0}, example: {}}}
      responses: {'202': {$ref: '#/components/responses/OperationOne'}, '409': {$ref: '#/components/responses/Problem'}}
  /tunnels/{tunnel_id}/actions/restart:
    post:
      tags: [Tunnels]
      summary: Restart the running tunnel
      operationId: restartTunnel
      x-required-scope: 'tunnels:control'
      parameters: [{$ref: '#/components/parameters/TunnelId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody:
        content: {application/json: {schema: {type: object, maxProperties: 0}, example: {}}}
      responses: {'202': {$ref: '#/components/responses/OperationOne'}, '409': {$ref: '#/components/responses/Problem'}}
  /tunnels/{tunnel_id}/extra-ports:
    parameters: [{$ref: '#/components/parameters/TunnelId'}]
    get: {tags: [Tunnels], summary: List extra ports, operationId: listExtraPorts, x-required-scope: 'tunnels:read', responses: {'200': {$ref: '#/components/responses/Success'}}}
    post:
      tags: [Tunnels]
      summary: Add an extra port
      operationId: createExtraPort
      x-required-scope: 'tunnels:write'
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ExtraPort'}}}}
      responses: {'201': {$ref: '#/components/responses/Success'}}
  /tunnels/{tunnel_id}/extra-ports/{port_id}:
    delete:
      tags: [Tunnels]
      summary: Delete an extra port
      operationId: deleteExtraPort
      x-required-scope: 'tunnels:write'
      parameters: [{$ref: '#/components/parameters/TunnelId'}, {$ref: '#/components/parameters/PortId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
      responses: {'200': {$ref: '#/components/responses/Success'}}
  /tunnels/{tunnel_id}/domain:
    parameters: [{$ref: '#/components/parameters/TunnelId'}]
    get: {tags: [Tunnels], summary: Get custom domain status, operationId: getTunnelDomain, x-required-scope: 'tunnels:read', responses: {'200': {$ref: '#/components/responses/Success'}}}
    put:
      tags: [Tunnels]
      summary: Assign a verified custom domain
      operationId: setTunnelDomain
      x-required-scope: 'tunnels:write'
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody: {required: true, content: {application/json: {schema: {type: object, required: [domain], properties: {domain: {type: string, format: hostname}}}}}}
      responses: {'200': {$ref: '#/components/responses/Success'}, '422': {$ref: '#/components/responses/Problem'}}
    delete:
      tags: [Tunnels]
      summary: Remove the custom domain
      operationId: removeTunnelDomain
      x-required-scope: 'tunnels:write'
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      responses: {'200': {$ref: '#/components/responses/Success'}}
  /tunnels/{tunnel_id}/domain/actions/verify:
    post:
      tags: [Tunnels]
      summary: Verify custom domain DNS
      operationId: verifyTunnelDomain
      x-required-scope: 'tunnels:write'
      parameters: [{$ref: '#/components/parameters/TunnelId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody: {required: true, content: {application/json: {schema: {type: object, required: [domain], properties: {domain: {type: string, format: hostname}}}}}}
      responses: {'200': {$ref: '#/components/responses/Success'}, '422': {$ref: '#/components/responses/Problem'}}
  /tunnels/{tunnel_id}/firewall:
    parameters: [{$ref: '#/components/parameters/TunnelId'}]
    get: {tags: [Tunnels], summary: Get firewall rules, operationId: getTunnelFirewall, x-required-scope: 'tunnels:read', responses: {'200': {$ref: '#/components/responses/Success'}}}
    put:
      tags: [Tunnels]
      summary: Replace firewall rules
      operationId: replaceTunnelFirewall
      x-required-scope: 'tunnels:write'
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody: {required: true, content: {application/json: {schema: {type: object, required: [rules], properties: {rules: {type: array, items: {$ref: '#/components/schemas/FirewallRule'}}}}}}}
      responses: {'200': {$ref: '#/components/responses/Success'}}
  /tunnels/{tunnel_id}/geoip:
    parameters: [{$ref: '#/components/parameters/TunnelId'}]
    get: {tags: [Tunnels], summary: Get GeoIP rules, operationId: getTunnelGeoip, x-required-scope: 'tunnels:read', responses: {'200': {$ref: '#/components/responses/Success'}}}
    put:
      tags: [Tunnels]
      summary: Replace GeoIP rules
      operationId: replaceTunnelGeoip
      x-required-scope: 'tunnels:write'
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody: {required: true, content: {application/json: {schema: {type: object, required: [rules], properties: {rules: {type: array, items: {type: object, required: [country_code], properties: {country_code: {type: string}, action: {type: string, enum: [allow, block]}}}}}}}}}
      responses: {'200': {$ref: '#/components/responses/Success'}}
  /tunnels/{tunnel_id}/move:
    post:
      tags: [Tunnels]
      summary: Move a tunnel to another node
      operationId: moveTunnel
      x-required-scope: 'tunnels:write'
      parameters: [{$ref: '#/components/parameters/TunnelId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody: {required: true, content: {application/json: {schema: {type: object, required: [node_id], properties: {node_id: {type: string, format: uuid}}}}}}
      responses: {'202': {$ref: '#/components/responses/Success'}}
  /tunnels/{tunnel_id}/usage:
    get: {tags: [Tunnels], summary: Get tunnel usage, operationId: getTunnelUsage, x-required-scope: 'tunnels:read', parameters: [{$ref: '#/components/parameters/TunnelId'}], responses: {'200': {$ref: '#/components/responses/Success'}}}
  /tunnels/{tunnel_id}/sessions:
    get: {tags: [Tunnels], summary: List tunnel sessions, operationId: listTunnelSessions, x-required-scope: 'tunnels:read', parameters: [{$ref: '#/components/parameters/TunnelId'}, {$ref: '#/components/parameters/Cursor'}, {$ref: '#/components/parameters/Limit'}], responses: {'200': {$ref: '#/components/responses/Success'}}}
  /devices:
    get: {tags: [Devices], summary: List devices, operationId: listDevices, x-required-scope: 'devices:read', parameters: [{$ref: '#/components/parameters/Cursor'}, {$ref: '#/components/parameters/Limit'}], responses: {'200': {$ref: '#/components/responses/DeviceList'}}}
  /devices/{device_id}:
    parameters: [{$ref: '#/components/parameters/DeviceId'}]
    get: {tags: [Devices], summary: Get a device, operationId: getDevice, x-required-scope: 'devices:read', responses: {'200': {$ref: '#/components/responses/Success'}}}
    patch:
      tags: [Devices]
      summary: Rename a device or disable remote control
      operationId: updateDevice
      x-required-scope: 'devices:write'
      description: May rename or disable remote control. Enabling always requires a local client action.
      requestBody: {required: true, content: {application/json: {schema: {type: object, properties: {name: {type: string}, remote_control_enabled: {type: boolean, const: false}}}}}}
      responses: {'200': {$ref: '#/components/responses/Success'}, '403': {$ref: '#/components/responses/Problem'}}
  /devices/{device_id}/actions/stop-all:
    post:
      tags: [Devices]
      summary: Stop every tunnel running on a device
      operationId: stopAllDeviceTunnels
      x-required-scope: 'devices:control'
      parameters: [{$ref: '#/components/parameters/DeviceId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
      responses: {'202': {$ref: '#/components/responses/OperationOne'}, '409': {$ref: '#/components/responses/Problem'}}
  /devices/{device_id}/actions/restart-agent:
    post:
      tags: [Devices]
      summary: Restart the Portwarp agent on a device
      operationId: restartDeviceAgent
      x-required-scope: 'devices:control'
      parameters: [{$ref: '#/components/parameters/DeviceId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
      responses: {'202': {$ref: '#/components/responses/OperationOne'}, '409': {$ref: '#/components/responses/Problem'}}
  /operations:
    get: {tags: [Operations], summary: List remote operations, operationId: listOperations, x-required-scope: 'operations:read', parameters: [{$ref: '#/components/parameters/Cursor'}, {$ref: '#/components/parameters/Limit'}], responses: {'200': {$ref: '#/components/responses/Success'}}}
  /operations/{operation_id}:
    get: {tags: [Operations], summary: Get a remote operation, operationId: getOperation, x-required-scope: 'operations:read', parameters: [{$ref: '#/components/parameters/OperationId'}], responses: {'200': {$ref: '#/components/responses/OperationOne'}}}
  /webhooks:
    get: {tags: [Webhooks], summary: List webhook endpoints, operationId: listWebhooks, x-required-scope: 'webhooks:read', parameters: [{$ref: '#/components/parameters/Cursor'}, {$ref: '#/components/parameters/Limit'}], responses: {'200': {$ref: '#/components/responses/Success'}}}
    post:
      tags: [Webhooks]
      summary: Create a webhook endpoint
      operationId: createWebhook
      description: The signing secret is returned once in this response. Each personal or Team API scope may keep up to 20 endpoints.
      x-required-scope: 'webhooks:write'
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/WebhookCreate'}}}}
      responses: {'201': {$ref: '#/components/responses/Success'}, '409': {$ref: '#/components/responses/Problem'}, '422': {$ref: '#/components/responses/Problem'}}
  /webhooks/{webhook_id}:
    parameters: [{$ref: '#/components/parameters/WebhookId'}]
    get: {tags: [Webhooks], summary: Get a webhook endpoint, operationId: getWebhook, x-required-scope: 'webhooks:read', responses: {'200': {$ref: '#/components/responses/Success'}}}
    patch: {tags: [Webhooks], summary: Update a webhook endpoint, operationId: updateWebhook, x-required-scope: 'webhooks:write', requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/WebhookUpdate'}}}}, responses: {'200': {$ref: '#/components/responses/Success'}}}
    delete: {tags: [Webhooks], summary: Delete a webhook endpoint, operationId: deleteWebhook, x-required-scope: 'webhooks:write', parameters: [{$ref: '#/components/parameters/IdempotencyKey'}], responses: {'200': {$ref: '#/components/responses/Success'}}}
  /webhooks/{webhook_id}/test:
    post: {tags: [Webhooks], summary: Send a test webhook, operationId: testWebhook, x-required-scope: 'webhooks:write', parameters: [{$ref: '#/components/parameters/WebhookId'}, {$ref: '#/components/parameters/IdempotencyKey'}], responses: {'202': {$ref: '#/components/responses/Success'}}}
  /webhooks/{webhook_id}/deliveries:
    get: {tags: [Webhooks], summary: List webhook delivery attempts, operationId: listWebhookDeliveries, x-required-scope: 'webhooks:read', parameters: [{$ref: '#/components/parameters/WebhookId'}, {$ref: '#/components/parameters/Cursor'}, {$ref: '#/components/parameters/Limit'}], responses: {'200': {$ref: '#/components/responses/Success'}}}
components:
  securitySchemes:
    bearerAuth: {type: http, scheme: bearer, bearerFormat: pw_v2}
  parameters:
    IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
    Cursor: {name: cursor, in: query, schema: {type: string}}
    Limit: {name: limit, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 25}}
    TunnelId: {name: tunnel_id, in: path, required: true, schema: {type: string, format: uuid}}
    DeviceId: {name: device_id, in: path, required: true, schema: {type: string, format: uuid}}
    OperationId: {name: operation_id, in: path, required: true, schema: {type: string, format: uuid}}
    WebhookId: {name: webhook_id, in: path, required: true, schema: {type: string, format: uuid}}
    PortId: {name: port_id, in: path, required: true, schema: {type: string, format: uuid}}
  requestBodies:
    TunnelUpdate:
      required: true
      content:
        application/json:
          schema:
            type: object
            minProperties: 1
            properties:
              name: {type: string}
              local_host: {type: string}
              local_port: {type: integer, minimum: 1, maximum: 65535}
              http_ssl_enabled: {type: boolean}
              http_basic_auth_enabled: {type: boolean}
              http_basic_auth_username: {type: [string, 'null']}
              http_basic_auth_password: {type: [string, 'null'], writeOnly: true}
              idle_timeout: {type: integer, minimum: 30, maximum: 3600}
              buffer_size: {type: integer, minimum: 1024, maximum: 1048576}
              pro_network: {type: boolean}
  responses:
    Success: {description: Success, content: {application/json: {schema: {$ref: '#/components/schemas/Envelope'}}}}
    TunnelOne: {description: Tunnel, content: {application/json: {schema: {allOf: [{$ref: '#/components/schemas/Envelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/Tunnel'}}}]}}}}
    TunnelList: {description: Tunnel list, content: {application/json: {schema: {allOf: [{$ref: '#/components/schemas/Envelope'}, {type: object, properties: {data: {type: array, items: {$ref: '#/components/schemas/Tunnel'}}}}]}}}}
    DeviceList: {description: Device list, content: {application/json: {schema: {allOf: [{$ref: '#/components/schemas/Envelope'}, {type: object, properties: {data: {type: array, items: {$ref: '#/components/schemas/Device'}}}}]}}}}
    OperationOne: {description: Accepted operation, content: {application/json: {schema: {allOf: [{$ref: '#/components/schemas/Envelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/Operation'}}}]}}}}
    Problem: {description: Problem details, content: {application/problem+json: {schema: {$ref: '#/components/schemas/Problem'}}}}
  schemas:
    Envelope:
      type: object
      required: [data, meta]
      properties: {data: {}, meta: {$ref: '#/components/schemas/Meta'}}
    Meta:
      type: object
      properties: {request_id: {type: string}, next_cursor: {type: [string, 'null']}}
    Problem:
      type: object
      required: [type, title, status, code, detail, request_id]
      properties:
        type: {type: string, format: uri}
        title: {type: string}
        status: {type: integer}
        code: {type: string}
        detail: {type: string}
        request_id: {type: string}
        errors: {type: array, items: {type: object, properties: {field: {type: string}, message: {type: string}}}}
    TunnelCreate:
      type: object
      required: [local_port]
      properties:
        name: {type: string}
        protocol: {type: string, enum: [tcp, udp, both, http], default: tcp}
        local_host: {type: string, default: 127.0.0.1}
        local_port: {type: integer, minimum: 1, maximum: 65535}
        region: {type: [string, 'null']}
        country: {type: [string, 'null']}
        game_preset_id: {type: [string, 'null'], format: uuid}
        subdomain: {type: [string, 'null']}
        http_ssl_enabled: {type: boolean, default: true}
        http_basic_auth_enabled: {type: boolean, default: false}
        http_basic_auth_username: {type: [string, 'null']}
        http_basic_auth_password: {type: [string, 'null'], writeOnly: true}
        pro_network: {type: boolean, default: true}
        device_ids: {type: array, items: {type: string, format: uuid}}
        extra_ports: {type: array, items: {$ref: '#/components/schemas/ExtraPort'}}
    Tunnel:
      type: object
      required: [id, protocol, local_port, remote_port, runtime, bindings]
      properties:
        id: {type: string, format: uuid}
        name: {type: string}
        protocol: {type: string}
        local_host: {type: string}
        local_port: {type: integer}
        remote_port: {type: integer}
        status: {type: string}
        runtime: {type: object, properties: {running: {type: boolean}, device_id: {type: [string, 'null'], format: uuid}}}
        bindings: {$ref: '#/components/schemas/DeviceBindings'}
        node: {type: object, description: Tunnel placement information.}
    DeviceBindings:
      type: object
      required: [device_ids]
      properties: {device_ids: {type: array, items: {type: string, format: uuid}}, unrestricted: {type: boolean, readOnly: true}}
    TunnelStartAction:
      type: object
      required: [device_id]
      properties: {device_id: {type: string, format: uuid}, handoff: {type: boolean, default: false}}
    ExtraPort:
      type: object
      required: [local_port]
      properties: {label: {type: [string, 'null']}, local_port: {type: integer, minimum: 1, maximum: 65535}, protocol: {type: string, enum: [tcp, udp, both, http]}}
    FirewallRule:
      type: object
      required: [rule_type]
      oneOf:
        - required: [ip_address]
          not: {required: [ip_cidr]}
        - required: [ip_cidr]
          not: {required: [ip_address]}
      properties:
        rule_type: {type: string, enum: [whitelist, blacklist]}
        ip_address: {type: string, description: One IPv4 or IPv6 address.}
        ip_cidr: {type: string, description: IPv4 or IPv6 address or CIDR range.}
        description: {type: [string, 'null'], maxLength: 255}
    Device:
      type: object
      properties: {id: {type: string, format: uuid}, owner_id: {type: string, format: uuid}, name: {type: [string, 'null']}, platform: {type: [string, 'null']}, version: {type: [string, 'null']}, online: {type: boolean}, remote_control_enabled: {type: boolean}, capabilities: {type: array, items: {type: string}}, last_seen_at: {type: [string, 'null'], format: date-time}}
    Operation:
      type: object
      properties:
        id: {type: string, format: uuid}
        status: {type: string, enum: [queued, claimed, running, succeeded, failed, expired, canceled]}
        action: {type: string}
        device_id: {type: string, format: uuid}
        tunnel_id: {type: [string, 'null'], format: uuid}
        result: {type: [object, 'null']}
        error:
          type: [object, 'null']
          properties: {code: {type: string}, detail: {type: string}}
        created_at: {type: [string, 'null'], format: date-time}
        claimed_at: {type: [string, 'null'], format: date-time}
        started_at: {type: [string, 'null'], format: date-time}
        finished_at: {type: [string, 'null'], format: date-time}
        expires_at: {type: string, format: date-time}
    WebhookCreate:
      type: object
      required: [url, events]
      properties:
        url: {type: string, format: uri, pattern: '^https://'}
        events:
          type: array
          minItems: 1
          uniqueItems: true
          items: {type: string, enum: [command.queued, command.claimed, command.running, command.succeeded, command.failed, command.expired, command.canceled, tunnel.started, tunnel.stopped, device.online, device.offline]}
        description: {type: [string, 'null']}
    WebhookUpdate:
      type: object
      minProperties: 1
      properties:
        url: {type: string, format: uri, pattern: '^https://'}
        events:
          type: array
          minItems: 1
          uniqueItems: true
          items: {type: string, enum: [command.queued, command.claimed, command.running, command.succeeded, command.failed, command.expired, command.canceled, tunnel.started, tunnel.stopped, device.online, device.offline]}
        description: {type: [string, 'null']}
        status: {type: string, enum: [enabled, disabled]}
