openapi: 3.1.0
info:
  title: InboxRhino API
  version: 1.0.0
  contact:
    name: InboxRhino
    url: https://inboxrhino.in
    email: contact@inboxrhino.in
  license:
    name: Proprietary
    url: https://inboxrhino.in/legal
  description: |
    Receive-only inboxes for automated tests.

    Base URL: `https://api.inboxrhino.in`
    Attachment downloads: `https://files.inboxrhino.in` (same Bearer key)
    Receiving domain: `test.inboxrhino.in` (SMTP only, not an HTTP API)

    Authenticate public `/v1` operations with
    `Authorization: Bearer ir_live_<16-hex>_<48-hex>`.
    Keys are created in the web console. They cannot manage billing, members, or other keys.

    Recommended agent/test flow:
    1. `createInbox`
    2. Send real mail to `data.address`
    3. `listOrWaitForMessages` with `wait_seconds=180`, `limit=1`, `include=content`
    4. Assert on the message
    5. `deleteInbox`

    A non-waiting empty list returns HTTP 200 `{ "data": [], "next_cursor": null }`.
    A wait timeout returns HTTP 204 with an empty body. Do not parse JSON on 204.
    `include=content` is valid only with `limit=1`.
    Invalid query values return 422; they are never clamped.
    Filters on messages are AND: `subject` (substring), `sender` (exact email),
    `sender_domain` (exact domain), `received_after` (RFC 3339).
    There is no org-wide message search and no send endpoint.
    Do not call `/console/*` or `/setup`.

    Errors always include a stable machine-readable `error.code` and `request_id`.
    The same id is returned as `X-Request-Id`.
  externalDocs:
    description: Human-readable API reference, Postman collection, and this OpenAPI file
    url: https://inboxrhino.in/docs/api
servers:
  - url: https://api.inboxrhino.in
    description: Public REST API
tags:
  - name: Health
    description: Unauthenticated liveness
  - name: Inboxes
    description: Create, list, inspect, and delete receive-only addresses
  - name: Messages
    description: List, wait for, inspect, and delete retained mail
  - name: Attachments
    description: Forced-download of stored attachment bytes
  - name: Usage
    description: Free-tier inbox and email quota
security:
  - bearerAuth: []
paths:
  /health:
    get:
      tags: [Health]
      security: []
      operationId: getHealth
      summary: Check API liveness
      description: No authentication. Returns a fixed payload when the Worker is up.
      responses:
        '200':
          description: Service is healthy
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [status, service]
                properties:
                  status: { const: ok }
                  service: { const: inboxrhino-api }
              example:
                status: ok
                service: inboxrhino-api
  /v1/inboxes:
    post:
      tags: [Inboxes]
      operationId: createInbox
      summary: Create an inbox
      description: |
        Creates the inbox and its exact Cloudflare Email Routing rule atomically
        from the caller's perspective. The JSON body may be omitted. The only
        allowed property is `prefix`. Unknown properties return 422 `unknown_property`.

        Prefix is normalized to lowercase. It must be 3–40 characters, match
        `^[a-z0-9](?:[a-z0-9-]{1,38})[a-z0-9]$`, and must not be a reserved
        service name (admin, administrator, abuse, postmaster, support, security,
        billing, root, hostmaster, webmaster, noreply, no-reply, donotreply,
        inboxrhino, contact). Omit prefix to receive a generated local-part.

        Idempotency keys are retained for 24 hours. Reusing a key with the same
        normalized body returns the cached 201. Reusing a key with a different
        body returns 409 `idempotency_key_reused`. An in-flight reuse returns
        409 `idempotency_in_progress` with `Retry-After: 1`.
      parameters:
        - in: header
          name: Idempotency-Key
          description: Optional. 8–200 characters. Scope is per organisation.
          schema: { type: string, minLength: 8, maxLength: 200 }
          example: signup-test-1
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                prefix:
                  type: string
                  minLength: 3
                  maxLength: 40
                  pattern: '^[A-Za-z0-9](?:[A-Za-z0-9-]{1,38})[A-Za-z0-9]$'
                  description: Normalized to lowercase; reserved service names are rejected.
                  example: signup-test
            example:
              prefix: signup-test
      responses:
        '201': { $ref: '#/components/responses/InboxResponse' }
        '401': { $ref: '#/components/responses/ErrorResponse' }
        '409': { $ref: '#/components/responses/ErrorResponse' }
        '422': { $ref: '#/components/responses/ErrorResponse' }
        '429': { $ref: '#/components/responses/ErrorResponse' }
        '502': { $ref: '#/components/responses/ErrorResponse' }
        '503': { $ref: '#/components/responses/ErrorResponse' }
    get:
      tags: [Inboxes]
      operationId: listInboxes
      summary: List inboxes
      description: |
        Lists active inboxes for the authenticated organisation, newest first.
        Default `limit` is 50, maximum 100. Invalid `limit` or `cursor` returns 422.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Active inboxes
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Inbox' } }
                  next_cursor: { type: [string, 'null'] }
              example:
                data:
                  - id: inbox_0123456789abcdef0123456789abcdef
                    address: signup-test@test.inboxrhino.in
                    local_part: signup-test
                    status: active
                    created_at: '2026-09-03T10:15:30.000Z'
                next_cursor: null
        '401': { $ref: '#/components/responses/ErrorResponse' }
        '422': { $ref: '#/components/responses/ErrorResponse' }
        '429': { $ref: '#/components/responses/ErrorResponse' }
  /v1/inboxes/{inbox_id}:
    parameters:
      - $ref: '#/components/parameters/InboxId'
    get:
      tags: [Inboxes]
      operationId: getInbox
      summary: Get an inbox
      description: Returns one active inbox owned by the organisation. Unknown or deleted ids return 404 `inbox_not_found`.
      responses:
        '200': { $ref: '#/components/responses/InboxResponse' }
        '401': { $ref: '#/components/responses/ErrorResponse' }
        '404': { $ref: '#/components/responses/ErrorResponse' }
        '429': { $ref: '#/components/responses/ErrorResponse' }
    delete:
      tags: [Inboxes]
      operationId: deleteInbox
      summary: Delete an inbox
      description: |
        Removes the Cloudflare Email Routing rule, then deletes the inbox and
        retained messages. Success is 204 with an empty body. This frees an
        active-inbox slot. It does not restore monthly inbound-email quota.
      responses:
        '204': { description: Inbox and retained messages deleted }
        '401': { $ref: '#/components/responses/ErrorResponse' }
        '404': { $ref: '#/components/responses/ErrorResponse' }
        '429': { $ref: '#/components/responses/ErrorResponse' }
        '502': { $ref: '#/components/responses/ErrorResponse' }
        '503': { $ref: '#/components/responses/ErrorResponse' }
  /v1/inboxes/{inbox_id}/messages:
    parameters:
      - $ref: '#/components/parameters/InboxId'
    get:
      tags: [Messages]
      operationId: listOrWaitForMessages
      summary: List or wait for messages
      description: |
        Returns matching retained messages immediately. When `wait_seconds` is
        greater than zero and no retained message matches, waits up to 180 seconds.
        An ordinary non-waiting empty list returns 200 with an empty `data` array;
        204 is reserved for a long-poll timeout and has an empty body.

        Filters are AND. Without `include=content`, items are summaries (no text,
        html, headers, or attachments). `include=content` requires `limit=1`.
        There is no organisation-wide message list.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - in: query
          name: wait_seconds
          description: Long-poll duration in seconds. 0 returns immediately.
          schema: { type: integer, minimum: 0, maximum: 180, default: 0 }
          example: 180
        - in: query
          name: subject
          description: Case-insensitive substring match.
          schema: { type: string }
          example: Verify
        - in: query
          name: sender
          description: Exact case-insensitive sender-address match.
          schema: { type: string, format: email }
          example: noreply@example.com
        - in: query
          name: sender_domain
          description: Exact case-insensitive sender-domain match. Do not include @.
          schema: { type: string }
          example: example.com
        - in: query
          name: received_after
          description: RFC 3339 timestamp. Only messages with received_at at or after this instant.
          schema: { type: string, format: date-time }
          example: '2026-09-03T10:00:00Z'
        - in: query
          name: include
          description: content is accepted only with limit=1.
          schema: { type: string, enum: [content] }
          example: content
      responses:
        '200':
          description: Matching messages, or a non-waiting empty list
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Message' } }
                  next_cursor: { type: [string, 'null'] }
        '204': { description: Wait ended without a matching message. Empty body. }
        '401': { $ref: '#/components/responses/ErrorResponse' }
        '404': { $ref: '#/components/responses/ErrorResponse' }
        '422': { $ref: '#/components/responses/ErrorResponse' }
        '429': { $ref: '#/components/responses/ErrorResponse' }
  /v1/messages/{message_id}:
    parameters:
      - in: path
        name: message_id
        required: true
        description: Message id returned by listOrWaitForMessages or getMessage.
        schema: { type: string }
        example: msg_0123456789abcdef0123456789abcdef
    get:
      tags: [Messages]
      operationId: getMessage
      summary: Get a message
      description: Returns the complete parsed message including text, html, headers, and attachments. No include query is required.
      responses:
        '200':
          description: Complete parsed message
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Message' }
        '404': { $ref: '#/components/responses/ErrorResponse' }
        '401': { $ref: '#/components/responses/ErrorResponse' }
        '429': { $ref: '#/components/responses/ErrorResponse' }
    delete:
      tags: [Messages]
      operationId: deleteMessage
      summary: Delete a message
      description: Deletes the message and stored objects. Success is 204. Does not restore monthly email quota.
      responses:
        '204': { description: Message and stored objects deleted }
        '401': { $ref: '#/components/responses/ErrorResponse' }
        '404': { $ref: '#/components/responses/ErrorResponse' }
        '429': { $ref: '#/components/responses/ErrorResponse' }
  /v1/attachments/{attachment_id}:
    parameters:
      - in: path
        name: attachment_id
        required: true
        description: Attachment id from a message payload. Canonical host is https://files.inboxrhino.in.
        schema: { type: string }
        example: att_0123456789abcdef0123456789abcdef
    get:
      tags: [Attachments]
      operationId: downloadAttachment
      summary: Download an attachment
      description: |
        Returns raw bytes with Content-Disposition attachment. Use the
        `download_url` from the message (https://files.inboxrhino.in/v1/attachments/{id}).
        The same path on api.inboxrhino.in is served by the same Worker.
        Authorization is still the organisation API key. Content is not malware-scanned.
      responses:
        '200':
          description: Forced attachment download; content is not malware-scanned.
          content:
            application/octet-stream: { schema: { type: string, format: binary } }
        '404': { $ref: '#/components/responses/ErrorResponse' }
        '401': { $ref: '#/components/responses/ErrorResponse' }
        '429': { $ref: '#/components/responses/ErrorResponse' }
  /v1/usage:
    get:
      tags: [Usage]
      operationId: getUsage
      summary: Get current usage
      description: |
        Current free-tier plan, UTC calendar-month window, active inbox count, and
        inbound emails received this period. Response is a bare object (no `data` wrapper).
        Free limits are 11 active inboxes and 33 emails per UTC month.
      responses:
        '200':
          description: Current free-tier usage and limits
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Usage' }
              example:
                plan: free
                period:
                  starts_at: '2026-09-01T00:00:00.000Z'
                  ends_at: '2026-10-01T00:00:00.000Z'
                inboxes: { active: 4, limit: 11, remaining: 7 }
                emails: { received: 21, limit: 33, remaining: 12 }
        '401': { $ref: '#/components/responses/ErrorResponse' }
        '429': { $ref: '#/components/responses/ErrorResponse' }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: ir_live API key
      description: |
        `Authorization: Bearer ir_live_<16 lowercase hex>_<48 lowercase hex>`.
        Create the key in the InboxRhino console. It is shown only once.
  parameters:
    InboxId:
      in: path
      name: inbox_id
      required: true
      description: Inbox id returned by createInbox (`inbox_` + 32 hex characters).
      schema: { type: string }
      example: inbox_0123456789abcdef0123456789abcdef
    Limit:
      in: query
      name: limit
      description: Integer page size. Invalid or out-of-range values return 422 rather than being clamped.
      schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      example: 50
    Cursor:
      in: query
      name: cursor
      description: Opaque cursor returned as `next_cursor` by the preceding page.
      schema: { type: string }
  responses:
    InboxResponse:
      description: Inbox
      content:
        application/json:
          schema:
            type: object
            required: [data]
            properties:
              data: { $ref: '#/components/schemas/Inbox' }
          example:
            data:
              id: inbox_0123456789abcdef0123456789abcdef
              address: signup-test@test.inboxrhino.in
              local_part: signup-test
              status: active
              created_at: '2026-09-03T10:15:30.000Z'
    ErrorResponse:
      description: |
        `{ "error": { "code", "message", "request_id" } }`.
        Branch on `code`. HTTP status is not a substitute for the code.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: inbox_not_found
              message: Inbox not found.
              request_id: 8f1a2c3d9e0b1a2c
  schemas:
    Inbox:
      type: object
      additionalProperties: false
      required: [id, address, local_part, status, created_at]
      properties:
        id: { type: string, example: inbox_0123456789abcdef0123456789abcdef }
        address: { type: string, format: email, example: signup-test@test.inboxrhino.in }
        local_part: { type: string, example: signup-test }
        status: { const: active }
        created_at: { type: string, format: date-time }
    Attachment:
      type: object
      additionalProperties: false
      required: [id, filename, content_type, size_bytes, disposition, download_url]
      properties:
        id: { type: string, example: att_0123456789abcdef0123456789abcdef }
        filename: { type: string, example: invoice.pdf }
        content_type: { type: string, example: application/pdf }
        size_bytes: { type: integer, example: 20480 }
        disposition: { type: string, enum: [attachment, inline] }
        content_id: { type: [string, 'null'] }
        download_url:
          type: string
          format: uri
          example: https://files.inboxrhino.in/v1/attachments/att_0123456789abcdef0123456789abcdef
    Message:
      type: object
      additionalProperties: false
      required: [id, inbox_id, from, to, subject, preview, size_bytes, received_at, expires_at]
      description: |
        List items without include=content omit internet_message_id, text, html, headers, and attachments.
        getMessage and include=content always include those fields.
      properties:
        id: { type: string, example: msg_0123456789abcdef0123456789abcdef }
        inbox_id: { type: string, example: inbox_0123456789abcdef0123456789abcdef }
        internet_message_id: { type: [string, 'null'] }
        from:
          type: object
          required: [name, address]
          properties:
            name: { type: string, example: Acme }
            address: { type: string, format: email, example: noreply@example.com }
        to: { type: string, format: email, example: signup-test@test.inboxrhino.in }
        subject: { type: string, example: Verify your email }
        preview: { type: string }
        size_bytes: { type: integer }
        received_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        text: { type: [string, 'null'] }
        html: { type: [string, 'null'] }
        headers:
          type: array
          items:
            type: array
            minItems: 2
            maxItems: 2
            prefixItems: [{ type: string }, { type: string }]
        attachments: { type: array, items: { $ref: '#/components/schemas/Attachment' } }
    Usage:
      type: object
      additionalProperties: false
      required: [plan, period, inboxes, emails]
      properties:
        plan: { const: free }
        period:
          type: object
          required: [starts_at, ends_at]
          properties:
            starts_at: { type: string, format: date-time }
            ends_at: { type: string, format: date-time }
        inboxes: { $ref: '#/components/schemas/Counter' }
        emails: { $ref: '#/components/schemas/Counter' }
    Counter:
      type: object
      additionalProperties: false
      required: [limit, remaining]
      properties:
        active: { type: integer }
        received: { type: integer }
        limit: { type: integer }
        remaining: { type: integer }
    Error:
      type: object
      additionalProperties: false
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, request_id]
          properties:
            code:
              type: string
              description: |
                Stable public /v1 codes:
                invalid_api_key, invalid_json, unknown_property, invalid_prefix,
                invalid_idempotency_key, idempotency_key_reused, idempotency_in_progress,
                idempotency_unavailable, inbox_quota_exceeded, address_unavailable,
                address_generation_failed, inbox_not_found, message_not_found,
                attachment_not_found, invalid_limit, invalid_cursor, invalid_include,
                invalid_wait_seconds, invalid_sender, invalid_sender_domain,
                invalid_received_after, rate_limit_exceeded, long_poll_limit_exceeded,
                email_routing_unavailable, internal_error.
              enum:
                - invalid_api_key
                - invalid_json
                - unknown_property
                - invalid_prefix
                - invalid_idempotency_key
                - idempotency_key_reused
                - idempotency_in_progress
                - idempotency_unavailable
                - inbox_quota_exceeded
                - address_unavailable
                - address_generation_failed
                - inbox_not_found
                - message_not_found
                - attachment_not_found
                - invalid_limit
                - invalid_cursor
                - invalid_include
                - invalid_wait_seconds
                - invalid_sender
                - invalid_sender_domain
                - invalid_received_after
                - rate_limit_exceeded
                - long_poll_limit_exceeded
                - email_routing_unavailable
                - internal_error
            message: { type: string }
            request_id: { type: string }
