openapi: 3.1.0

info:
  title: AsyncBase API
  version: 0.1.0
  description: |
    AsyncBase queue primitive API. Pull-first, SQS-compatible semantics with a
    dashboard-first UX. This spec is the single source of truth (SSOT) and drives
    SDK code generation for Node (@asyncbase/sdk), Python (asyncbase), and PHP
    (asyncbase/asyncbase).

    Every error response shares a common `Error` schema with `code`, `message`,
    and a `docs` URL pointing to the documentation for that error code.

    Idempotency: every mutating operation (enqueue, ack) accepts an
    `Idempotency-Key` header. Servers deduplicate identical requests within a
    24-hour window and return `Idempotency-Replay: true` on a cached response.
  contact:
    name: AsyncBase
    url: https://asyncbase.dev
  license:
    name: MIT
    url: https://opensource.org/licenses/MIT

servers:
  - url: http://localhost:3000
    description: Local development (docker-compose)
  - url: https://api.asyncbase.dev
    description: Production

security:
  - bearerAuth: []

tags:
  - name: health
    description: Service liveness and readiness
  - name: queue
    description: Queue message operations (enqueue, dequeue, ack)

paths:
  /health:
    get:
      tags: [health]
      summary: Health check
      description: |
        Returns the liveness state of the API and its downstream dependencies
        (Redis, Postgres). Public endpoint — no authentication required.
      security: []
      operationId: getHealth
      responses:
        "200":
          description: Service healthy (all dependencies reachable)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthResponse"
              examples:
                healthy:
                  summary: All green
                  value:
                    status: ok
                    version: "0.1.0"
                    dependencies:
                      redis: ok
                      postgres: ok
                    timestamp: "2026-04-18T04:05:06.789Z"
        "503":
          description: One or more dependencies unreachable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthResponse"
              examples:
                degraded:
                  value:
                    status: degraded
                    version: "0.1.0"
                    dependencies:
                      redis: ok
                      postgres: error
                    timestamp: "2026-04-18T04:05:06.789Z"

  /v1/queues/{name}/messages:
    parameters:
      - $ref: "#/components/parameters/QueueName"

    post:
      tags: [queue]
      summary: Enqueue a message
      description: |
        Append a message to the named queue. Returns the server-assigned message
        ID on success.

        The `Idempotency-Key` header is strongly recommended: on retry with the
        same key within 24 hours, the original message ID is returned without a
        second write, and the response includes `Idempotency-Replay: true`.

        For FIFO (ordered + dedup) behavior, provide `group` and optionally
        `dedupe_id`. Messages with the same `dedupe_id` within the dedup window
        are collapsed to a single enqueue.
      operationId: enqueueMessage
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EnqueueRequest"
            examples:
              minimal:
                summary: Send an email (minimal)
                value:
                  payload:
                    to: user@example.com
                    template: welcome
              withOptions:
                summary: Delayed, bounded retry, FIFO
                value:
                  payload:
                    to: user@example.com
                    template: receipt
                  delay: 5s
                  retries: 3
                  ttl: 7d
                  fifo_group: payment-service
                  dedupe_id: inv_12345
      responses:
        "201":
          description: Message enqueued
          headers:
            Idempotency-Replay:
              $ref: "#/components/headers/IdempotencyReplay"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnqueueResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/QueueNotFound"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"

    get:
      tags: [queue]
      summary: Dequeue messages (pull)
      description: |
        Pull up to `limit` messages from the queue using consumer group
        semantics. Each message is delivered at-least-once. The caller must
        `ack` each message before the visibility timeout (`visibility_seconds`)
        or it will be re-delivered to another consumer in the same group.

        Use `wait` > 0 for long polling (server blocks up to `wait` seconds for
        new messages — Redis `XREAD BLOCK` is used server-side, so concurrent
        long polls share one backend connection).
      operationId: dequeueMessages
      parameters:
        - name: limit
          in: query
          description: Maximum number of messages to return
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 10
        - name: wait
          in: query
          description: |
            Long polling: server waits up to `wait` seconds for new messages
            before responding (0 = no wait, 20 = max).
          required: false
          schema:
            type: integer
            minimum: 0
            maximum: 20
            default: 0
        - name: group
          in: query
          description: |
            Consumer group name. Messages pulled by the same group are
            load-balanced across consumers. Different groups each get the
            full message stream independently.
          required: true
          schema:
            type: string
            minLength: 1
            maxLength: 128
            pattern: "^[a-zA-Z0-9_-]+$"
        - name: consumer
          in: query
          description: |
            Consumer instance ID within the group. Distinct consumers in the
            same group can process different messages in parallel.
          required: false
          schema:
            type: string
            minLength: 1
            maxLength: 128
            pattern: "^[a-zA-Z0-9_-]+$"
        - name: visibility_seconds
          in: query
          description: |
            Seconds the message stays invisible after being pulled. Call
            heartbeat to extend, or ack to delete. Default 30s.
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 3600
            default: 30
      responses:
        "200":
          description: 0 or more messages returned (empty array on timeout)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DequeueResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/QueueNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/queues/{name}/messages/{id}/ack:
    parameters:
      - $ref: "#/components/parameters/QueueName"
      - $ref: "#/components/parameters/MessageId"

    post:
      tags: [queue]
      summary: Acknowledge a message
      description: |
        Mark a message as successfully processed. After ack, the message is
        removed from the pending list and will not be re-delivered.

        Idempotent: acking an already-acked message returns 200 with
        `already_acked: true`.
      operationId: ackMessage
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Message acknowledged
          headers:
            Idempotency-Replay:
              $ref: "#/components/headers/IdempotencyReplay"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AckResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/MessageNotFound"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: sk_live_xxx or sk_test_xxx
      description: |
        API key passed as `Authorization: Bearer sk_live_xxxxx`.
        Obtain via the dashboard (Settings → API keys).

  parameters:
    QueueName:
      name: name
      in: path
      required: true
      description: Queue name (unique per tenant).
      schema:
        type: string
        minLength: 1
        maxLength: 128
        pattern: "^[a-zA-Z0-9_-]+$"
      examples:
        emails:
          value: emails
        aiJobs:
          value: ai-jobs
    MessageId:
      name: id
      in: path
      required: true
      description: Server-assigned message ID.
      schema:
        type: string
        pattern: "^msg_[a-zA-Z0-9]+$"
      examples:
        sample:
          value: msg_7f3a2c9b
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Client-generated unique key for safe retries. UUID or similarly unique
        string. Within 24 hours, duplicate requests with the same key return
        the first response and set `Idempotency-Replay: true`.
      schema:
        type: string
        minLength: 8
        maxLength: 128
      examples:
        uuid:
          value: 5d7f9f2e-4b21-4f6a-9b37-0a4c6d3e1f2a

  headers:
    IdempotencyReplay:
      description: |
        Present and `true` when this response is a cached replay of a prior
        request with the same `Idempotency-Key`.
      schema:
        type: boolean

  schemas:
    # ─── Error envelope ──────────────────────────────────────────
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, docs]
          properties:
            code:
              $ref: "#/components/schemas/ErrorCode"
            message:
              type: string
              description: Human-readable error message.
            docs:
              type: string
              format: uri
              description: URL to documentation for this error code.
            request_id:
              type: string
              description: Server-generated request ID for support diagnostics.
      example:
        error:
          code: QUEUE_NOT_FOUND
          message: "Queue 'emails' does not exist or you don't have access"
          docs: https://asyncbase.dev/docs/errors/QUEUE_NOT_FOUND
          request_id: req_9a3b7c2d

    ErrorCode:
      type: string
      description: |
        Machine-readable error code. Stable across versions. Every code has a
        documentation page at `https://asyncbase.dev/docs/errors/{code}`.

        Note: `already_acked` on a repeat ack is a success signal (returned as
        a boolean field on `AckResponse`, not an error).

        `QUEUE_LIMIT_REACHED` is reserved for the future create-queue endpoint
        (not in MVP). When wired, it maps to HTTP 403.
      enum:
        - QUEUE_NOT_FOUND
        - MESSAGE_NOT_FOUND
        - AUTH_INVALID
        - AUTH_KEY_EXPIRED
        - RATE_LIMITED
        - PAYLOAD_TOO_LARGE
        - QUEUE_LIMIT_REACHED
        - MESSAGE_LIMIT_REACHED
        - QUEUE_NAME_INVALID

    # ─── Health ──────────────────────────────────────────────────
    HealthResponse:
      type: object
      required: [status, version, dependencies, timestamp]
      properties:
        status:
          type: string
          enum: [ok, degraded]
        version:
          type: string
          description: Deployed API version.
        dependencies:
          type: object
          required: [redis, postgres]
          properties:
            redis:
              type: string
              enum: [ok, error]
            postgres:
              type: string
              enum: [ok, error]
        timestamp:
          type: string
          format: date-time

    # ─── Enqueue ─────────────────────────────────────────────────
    EnqueueRequest:
      type: object
      required: [payload]
      properties:
        payload:
          description: |
            Arbitrary JSON message body. Serialized size must not exceed 256KB
            (validated server-side; exceeding returns 413 PAYLOAD_TOO_LARGE).
          type: object
          additionalProperties: true
          x-asyncbase-max-bytes: 262144
        delay:
          $ref: "#/components/schemas/Duration"
        retries:
          type: integer
          description: Max delivery attempts before moving to DLQ. Default 3.
          minimum: 0
          maximum: 50
          default: 3
        ttl:
          $ref: "#/components/schemas/Duration"
        fifo_group:
          type: string
          description: |
            FIFO ordering key. Messages with the same `fifo_group` are
            delivered in order. Omit for standard (best-effort ordering)
            queue behavior.

            Distinct from the dequeue-side `group` query param (which is a
            consumer group, standard Redis Streams terminology).
          minLength: 1
          maxLength: 128
          pattern: "^[a-zA-Z0-9_-]+$"
        dedupe_id:
          type: string
          description: |
            Dedup identifier. Within the dedup window, duplicate enqueues with
            the same `dedupe_id` are collapsed. **Requires `fifo_group` to be
            set** (server returns 400 with `QUEUE_NAME_INVALID`-like error if
            `dedupe_id` is provided without `fifo_group`).
          minLength: 1
          maxLength: 128

    EnqueueResponse:
      type: object
      required: [id, enqueued_at]
      properties:
        id:
          type: string
          pattern: "^msg_[a-zA-Z0-9]+$"
        enqueued_at:
          type: string
          format: date-time
        deliver_at:
          type: string
          format: date-time
          description: Present when `delay` was specified.
        deduped:
          type: boolean
          description: |
            True when `dedupe_id` matched a prior message within the window.
            The returned `id` is the original message's ID.

    # ─── Dequeue ─────────────────────────────────────────────────
    DequeueResponse:
      type: object
      required: [messages]
      properties:
        messages:
          type: array
          items:
            $ref: "#/components/schemas/Message"

    Message:
      type: object
      required: [id, payload, enqueued_at, attempt, ack_deadline]
      properties:
        id:
          type: string
          pattern: "^msg_[a-zA-Z0-9]+$"
        payload:
          type: object
          additionalProperties: true
        enqueued_at:
          type: string
          format: date-time
        attempt:
          type: integer
          minimum: 1
          description: 1 = first delivery; > 1 = redelivery after previous nack/timeout.
        ack_deadline:
          type: string
          format: date-time
          description: Caller must ack or heartbeat before this time.
        fifo_group:
          type: string
          description: FIFO ordering key if set at enqueue.
        metadata:
          type: object
          description: Server-managed metadata (consumer, retry history, etc.).
          additionalProperties: true

    # ─── Ack ─────────────────────────────────────────────────────
    AckResponse:
      type: object
      required: [id, acked_at]
      properties:
        id:
          type: string
          pattern: "^msg_[a-zA-Z0-9]+$"
        acked_at:
          type: string
          format: date-time
        already_acked:
          type: boolean
          description: True when the message was already acked by a prior call.

    # ─── Shared ──────────────────────────────────────────────────
    Duration:
      type: string
      description: |
        Duration string. Accepts `5s`, `30m`, `2h`, `7d`.
        Minimum unit is seconds — delayed delivery resolves with 1-second
        precision via the worker ZSET poller (see architecture docs).
        Milliseconds are not supported to keep the contract honest.
      pattern: "^[0-9]+(s|m|h|d)$"
      example: 5s

  responses:
    BadRequest:
      description: Invalid request (bad schema, invalid values).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            invalidName:
              value:
                error:
                  code: QUEUE_NAME_INVALID
                  message: "Queue name 'my queue!' contains invalid characters"
                  docs: https://asyncbase.dev/docs/errors/QUEUE_NAME_INVALID

    Unauthorized:
      description: Missing, invalid, or expired API key.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            invalid:
              value:
                error:
                  code: AUTH_INVALID
                  message: "API key is invalid or has been revoked"
                  docs: https://asyncbase.dev/docs/errors/AUTH_INVALID
            expired:
              value:
                error:
                  code: AUTH_KEY_EXPIRED
                  message: "API key has expired after rotation grace period"
                  docs: https://asyncbase.dev/docs/errors/AUTH_KEY_EXPIRED

    QueueNotFound:
      description: Queue does not exist or is not accessible to this tenant.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: QUEUE_NOT_FOUND
              message: "Queue 'emails' does not exist"
              docs: https://asyncbase.dev/docs/errors/QUEUE_NOT_FOUND

    MessageNotFound:
      description: Message ID does not exist in this queue (or belongs to another queue/tenant).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: MESSAGE_NOT_FOUND
              message: "Message 'msg_7f3a2c' not found in queue 'emails'"
              docs: https://asyncbase.dev/docs/errors/MESSAGE_NOT_FOUND

    PayloadTooLarge:
      description: Serialized payload exceeds 256KB.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: PAYLOAD_TOO_LARGE
              message: "Payload 312KB exceeds the 256KB limit"
              docs: https://asyncbase.dev/docs/errors/PAYLOAD_TOO_LARGE

    RateLimited:
      description: |
        Request rate exceeded the plan's limit. Retry after the `Retry-After`
        header (seconds).
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds to wait before retrying.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: RATE_LIMITED
              message: "Exceeded 500 req/s Starter plan rate limit"
              docs: https://asyncbase.dev/docs/errors/RATE_LIMITED
