openapi: 3.1.0
info:
  title: RelayKey Management API
  version: "0.1.0"
  description: |
    The RelayKey management API lets you create connections (an upstream API plus
    its master key), issue delegated credentials (scoped, expiring proxy tokens
    you hand to consultants, vendors, or AI agents), and read audit events.

    For coding agents (Claude Code, Codex, Cursor, Replit Agent, Devin) the
    canonical migration walkthrough lives at:
      - https://relaykey.ai/agents
      - https://relaykey.ai/llms-full.txt

    All requests authenticate with a bearer management token in the
    `Authorization` header:

        Authorization: Bearer rk_mgmt_xxxxxxxxxxxx

    Management tokens have a role: `owner`, `admin`, or `provisioner`.
    A `provisioner` token has additional constraints (`max_ttl_seconds`,
    `allowed_methods`, `allowed_paths`). When a provisioner token creates a
    delegated credential, the new credential's permissions cannot exceed those
    constraints. Requests that would exceed them return `403` with
    `reason: scope_exceeds_provisioner`.
servers:
  - url: https://api.relaykey.ai
    description: Production
security:
  - bearerAuth: []
tags:
  - name: connections
  - name: delegated-credentials
  - name: audit
  - name: identity
paths:
  /v1/connections:
    post:
      tags: [connections]
      summary: Create a connection
      description: |
        Create a connection to an upstream API. The upstream key is encrypted
        at rest and never returned by any endpoint after creation.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ConnectionCreate'
            examples:
              elevenlabs-header:
                summary: ElevenLabs (custom header auth)
                value:
                  name: "ElevenLabs (production)"
                  base_url: "https://api.elevenlabs.io"
                  auth_type: "header"
                  auth_header_name: "xi-api-key"
                  upstream_key: "sk-elevenlabs-master-xxxxxxxx"
              hubspot-bearer:
                summary: HubSpot (bearer token)
                value:
                  name: "HubSpot (acme)"
                  base_url: "https://api.hubapi.com"
                  auth_type: "bearer"
                  upstream_key: "pat-na1-xxxxxxxx"
      responses:
        '201':
          description: Connection created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Connection'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    get:
      tags: [connections]
      summary: List connections
      parameters:
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: cursor
          schema: { type: string }
      responses:
        '200':
          description: A page of connections
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConnectionList'
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/connections/{id}:
    get:
      tags: [connections]
      summary: Get a connection
      parameters:
        - $ref: '#/components/parameters/IdParam'
      responses:
        '200':
          description: The connection
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Connection'
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/delegated-credentials:
    post:
      tags: [delegated-credentials]
      summary: Create a delegated credential
      description: |
        Issue a scoped, time-boxed proxy credential. The returned `proxy_token`
        is shown exactly once. It cannot be retrieved later.

        Defaults: `allowed_methods = ["GET"]`, `expires_in_seconds = 86400`
        (24 hours).

        If the management token is a `provisioner`, the requested scope must
        be a subset of the provisioner's constraints.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DelegatedCredentialCreate'
            examples:
              read-only-voices:
                summary: GET-only, voices endpoints, 24h
                value:
                  connection_id: "conn_2nE3xL"
                  name: "Voice clone consultant — May"
                  allowed_methods: ["GET"]
                  allowed_paths: ["/v1/voices", "/v1/voices/*"]
                  allowed_ips: ["198.51.100.7", "198.51.100.0/24"]
                  expires_in_seconds: 86400
      responses:
        '201':
          description: Credential created. `proxy_token` is shown once.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DelegatedCredentialIssued'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Scope exceeds provisioner constraints
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                reason: "scope_exceeds_provisioner"
                message: "expires_in_seconds 604800 exceeds provisioner max_ttl_seconds 86400"
    get:
      tags: [delegated-credentials]
      summary: List delegated credentials
      parameters:
        - in: query
          name: connection_id
          schema: { type: string }
        - in: query
          name: status
          schema:
            type: string
            enum: [active, expired, revoked]
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: cursor
          schema: { type: string }
      responses:
        '200':
          description: A page of credentials
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DelegatedCredentialList'
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/delegated-credentials/{id}:
    get:
      tags: [delegated-credentials]
      summary: Get a delegated credential
      parameters:
        - $ref: '#/components/parameters/IdParam'
      responses:
        '200':
          description: The credential (without proxy_token)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DelegatedCredential'
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/delegated-credentials/{id}/revoke:
    post:
      tags: [delegated-credentials]
      summary: Revoke a delegated credential
      description: |
        Immediately revoke a delegated credential. The next proxy request using
        the credential returns `401` with `reason: revoked`.
      parameters:
        - $ref: '#/components/parameters/IdParam'
      responses:
        '200':
          description: Revoked
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DelegatedCredential'
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/audit:
    get:
      tags: [audit]
      summary: List audit events
      parameters:
        - in: query
          name: connection_id
          schema: { type: string }
        - in: query
          name: credential_id
          schema: { type: string }
        - in: query
          name: decision
          schema:
            type: string
            enum: [allowed, blocked]
        - in: query
          name: since
          schema: { type: string, format: date-time }
        - in: query
          name: until
          schema: { type: string, format: date-time }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
        - in: query
          name: cursor
          schema: { type: string }
      responses:
        '200':
          description: A page of audit events
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuditEventList'
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/me:
    get:
      tags: [identity]
      summary: Information about the authenticated management token
      responses:
        '200':
          description: Token info
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Me'
              examples:
                owner:
                  summary: Owner token (no constraints)
                  value:
                    org_id: "org_2pQ"
                    role: "owner"
                    constraints: null
                provisioner:
                  summary: Provisioner token with scoped constraints
                  value:
                    org_id: "org_2pQ"
                    role: "provisioner"
                    constraints:
                      max_ttl_seconds: 86400
                      allowed_methods: ["GET"]
                      allowed_paths: ["/v1/voices", "/v1/voices/*"]
        '401': { $ref: '#/components/responses/Unauthorized' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: rk_mgmt
  parameters:
    IdParam:
      in: path
      name: id
      required: true
      schema:
        type: string
  responses:
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { reason: "invalid_request", message: "auth_header_name required when auth_type is header" }
    Unauthorized:
      description: Missing or invalid management token
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { reason: "unauthorized", message: "Invalid or expired management token" }
    NotFound:
      description: Not found
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { reason: "not_found", message: "Resource not found" }
  schemas:
    Error:
      type: object
      required: [reason, message]
      properties:
        reason:
          type: string
          description: Stable machine-readable code.
          examples: ["invalid_request", "unauthorized", "not_found", "scope_exceeds_provisioner"]
        message:
          type: string
          description: Human-readable explanation.
    ConnectionCreate:
      type: object
      required: [name, base_url, auth_type, upstream_key]
      properties:
        name:
          type: string
          example: "ElevenLabs (production)"
        base_url:
          type: string
          format: uri
          example: "https://api.elevenlabs.io"
        auth_type:
          type: string
          enum: [bearer, header]
        auth_header_name:
          type: string
          description: Required when auth_type is `header`.
          example: "xi-api-key"
        upstream_key:
          type: string
          description: The real API key. Encrypted at rest. Never returned again.
          writeOnly: true
          example: "sk-master-xxxxxxxx"
    Connection:
      type: object
      required: [id, name, base_url, auth_type, created_at]
      properties:
        id:
          type: string
          example: "conn_2nE3xL"
        name:
          type: string
          example: "ElevenLabs (production)"
        base_url:
          type: string
          format: uri
          example: "https://api.elevenlabs.io"
        auth_type:
          type: string
          enum: [bearer, header]
          example: "header"
        auth_header_name:
          type: string
          nullable: true
          example: "xi-api-key"
        upstream_key_last4:
          type: string
          example: "x8a2"
          description: Last 4 characters of the master key, for display only.
        created_at:
          type: string
          format: date-time
    ConnectionList:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Connection' }
        next_cursor:
          type: string
          nullable: true
    DelegatedCredentialCreate:
      type: object
      required: [connection_id, name, allowed_paths]
      properties:
        connection_id:
          type: string
          example: "conn_2nE3xL"
        name:
          type: string
          example: "Voice clone consultant — May"
        allowed_methods:
          type: array
          items:
            type: string
            enum: [GET, POST, PUT, PATCH, DELETE]
          default: ["GET"]
          example: ["GET"]
        allowed_paths:
          type: array
          minItems: 1
          items:
            type: string
          description: |
            Path patterns. Glob-style with `*` matching a single path segment.
            Examples: `/v1/voices`, `/v1/voices/*`.
          example: ["/v1/voices", "/v1/voices/*"]
        allowed_ips:
          type: array
          items:
            type: string
          description: |
            Source IP allowlist. Each entry is a single IPv4/IPv6 address or a
            CIDR block. Empty/omitted means any source IP is allowed. The proxy
            resolves the client IP using `request.ip`; deployments behind a
            load balancer must configure `RELAYKEY_TRUSTED_PROXIES` so that
            `X-Forwarded-For` is trusted only from those edges.
          example: ["198.51.100.7", "198.51.100.0/24"]
        expires_in_seconds:
          type: integer
          minimum: 60
          maximum: 7776000
          default: 86400
          description: Time-to-live in seconds. Default 24 hours. Max 90 days.
          example: 86400
    DelegatedCredential:
      type: object
      required: [id, connection_id, name, allowed_methods, allowed_paths, expires_at, status, created_at]
      properties:
        id:
          type: string
          example: "dcred_7f3aQ"
        connection_id:
          type: string
          example: "conn_2nE3xL"
        name:
          type: string
          example: "Voice clone consultant — May"
        allowed_methods:
          type: array
          items: { type: string, enum: [GET, POST, PUT, PATCH, DELETE] }
        allowed_paths:
          type: array
          items: { type: string }
        allowed_ips:
          type: array
          items: { type: string }
          description: |
            Source IP allowlist (CIDRs or single addresses). Empty array means
            any source IP is allowed.
          example: ["198.51.100.7", "198.51.100.0/24"]
        expires_at:
          type: string
          format: date-time
        status:
          type: string
          enum: [active, expired, revoked]
        created_at:
          type: string
          format: date-time
        revoked_at:
          type: string
          format: date-time
          nullable: true
    DelegatedCredentialIssued:
      allOf:
        - $ref: '#/components/schemas/DelegatedCredential'
        - type: object
          required: [proxy_token, proxy_base_url]
          properties:
            proxy_token:
              type: string
              description: |
                The bearer token to send to the consultant or agent. Shown
                exactly once. RelayKey cannot retrieve it later.
              example: "rk_proxy_7f3aQwertyuiopAsdfg"
            proxy_base_url:
              type: string
              format: uri
              description: Where the consumer should send proxy requests.
              example: "https://proxy.relaykey.ai/conn_2nE3xL"
    DelegatedCredentialList:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/DelegatedCredential' }
        next_cursor:
          type: string
          nullable: true
    AuditEvent:
      type: object
      required: [id, org_id, connection_id, method, path, status_code, decision, timestamp]
      properties:
        id:
          type: string
          example: "evt_9aQz"
        org_id:
          type: string
          example: "org_2pQ"
        connection_id:
          type: string
          example: "conn_2nE3xL"
        credential_id:
          type: string
          nullable: true
          example: "dcred_7f3aQ"
        method:
          type: string
          example: "GET"
        path:
          type: string
          example: "/v1/voices"
        status_code:
          type: integer
          example: 200
        decision:
          type: string
          enum: [allowed, blocked]
        block_reason:
          type: string
          nullable: true
          enum: [method_not_allowed, path_not_allowed, ip_not_allowed, expired, revoked, invalid_token, connection_not_found, scope_exceeds_provisioner, upstream_error]
        timestamp:
          type: string
          format: date-time
    AuditEventList:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/AuditEvent' }
        next_cursor:
          type: string
          nullable: true
    ProvisionerConstraints:
      type: object
      required: [max_ttl_seconds, allowed_methods, allowed_paths]
      properties:
        max_ttl_seconds:
          type: integer
          minimum: 60
          maximum: 7776000
          example: 86400
        allowed_methods:
          type: array
          items:
            type: string
            enum: [GET, POST, PUT, PATCH, DELETE]
          example: ["GET"]
        allowed_paths:
          type: array
          items: { type: string }
          example: ["/v1/voices", "/v1/voices/*"]
        allowed_ips:
          type: array
          items: { type: string }
          description: |
            Source IP allowlist applied to the management token itself, and
            an upper bound for any delegated credential it issues — the new
            credential's `allowed_ips` must be a subset (every CIDR fully
            contained by some provisioner CIDR). Empty means no constraint.
          example: ["198.51.100.0/24"]
    Me:
      type: object
      required: [org_id, role]
      properties:
        org_id:
          type: string
          example: "org_2pQ"
        role:
          type: string
          enum: [owner, admin, provisioner]
        constraints:
          oneOf:
            - $ref: '#/components/schemas/ProvisionerConstraints'
            - type: 'null'
          description: Present only when role is `provisioner`.
