# HTTP API Documentation

## Overview

The HTTP API provides publish, query, and fetch endpoints for clients that do not need persistent subscriptions. For persistent subscribe/deliver/ack workflows, use the [TCP protocol](protocol.md).

## Base URL

All API endpoints are served at `http://{bind_address}:{port}` as configured in `[http]`. Default: `http://127.0.0.1:8080`.

## Authentication

Three authentication models are used depending on the endpoint:

- **Health endpoints** (`/health/*`): No authentication required.
- **Business endpoints** (`/v1/*`): Require `Authorization: Bearer <api_key>` header. The API key determines the namespace and permissions. The namespace in the URL path must match the API key's namespace — mismatches are rejected with `401`.
- **Admin endpoints** (`/admin/*`): Require `Authorization: Bearer <admin_api_key>` header. The admin API key is configured in the `[deploy]` section (see [deploy guide](deploy.md)). If not configured, admin endpoints return `403`.

## Endpoints

### Health Endpoints

Health endpoints require no authentication and are available immediately when the process starts (before `engine.init()` completes).

#### `GET /health/live`

Liveness probe. Returns 200 as long as the process is running. Use for Kubernetes liveness probes or systemd watchdog.

**Response:** `200 OK`

```json
{
  "status": "alive"
}
```

#### `GET /health/ready`

Readiness probe. Returns 200 only when the server is fully initialized and in `Running` state. Returns 503 during initialization, drain, and shutdown. Use for Kubernetes readiness probes and load balancer health checks.

**Response:**

| Status | Body | When |
|--------|------|------|
| `200 OK` | `{ "status": "ready" }` | Server initialized and running |
| `503 Service Unavailable` | `{ "status": "initializing" }` | `engine.init()` not yet complete |
| `503 Service Unavailable` | `{ "status": "draining" }` | Server is draining connections |
| `503 Service Unavailable` | `{ "status": "shutting_down" }` | Server is shutting down |

#### `GET /health`

Alias for `/health/ready`. Existing clients that check `/health` get readiness-aware behavior automatically.

> **Note:** This is a behavior change from pre-deploy versions where `/health` always returned 200. A draining server must not receive new traffic from a load balancer. Operators who need unconditional 200 should use `/health/live`.

---

### `POST /v1/{namespace}/{topic}/publish`

Publish a message to a topic. The topic is auto-created on first publish.

**Headers:**

| Header | Required | Value |
|--------|----------|-------|
| `Authorization` | Yes | `Bearer <api_key>` |
| `X-Fafb-*` | No | Message headers. Any HTTP header prefixed with `X-Fafb-` is stripped of the prefix and attached as a message header (see below). |

**Path parameters:**

| Parameter | Description |
|-----------|-------------|
| `namespace` | Target namespace (must match API key's namespace) |
| `topic` | Target topic name |

**Topic name validation:** The topic in the URL path must be a valid publish target — max 255 bytes, must not contain `*` or `>` characters (reserved for wildcard subscription patterns), and must not have empty segments (`..`, leading `.`, trailing `.`). Invalid topics return `400 Bad Request`.

**Query parameters:**

| Parameter | Required | Description |
|-----------|----------|-------------|
| `ttl` | No | Time-to-live in **seconds**. Messages with TTL are automatically skipped during delivery/fetch after `ttl` seconds have elapsed since publish. `0` or absent = no expiry. Converted to milliseconds internally. Subject to namespace `min_ttl_secs`/`max_ttl_secs` limits — values below minimum are rejected (400), values above maximum are clamped. If the namespace has `default_ttl_secs` configured, messages without an explicit TTL inherit the default. |

**Request body:** Raw bytes — the message payload. Content-Type is not enforced.

**Message headers via HTTP:** Pass message headers as HTTP headers with the `X-Fafb-` prefix. The server strips the prefix and stores the remainder as the header key:

```
X-Fafb-Content-Type: application/json     → header key: "content-type"
X-Fafb-Correlation-Id: req-12345          → header key: "correlation-id"
X-Fafb-Trace-Parent: 00-abcdef...         → header key: "trace-parent"
```

> **Note:** HTTP headers are lowercased by the transport layer (per HTTP/2 spec and hyper's implementation), so `X-Fafb-Content-Type` becomes key `content-type`. TCP clients preserve original key casing. This is inherent to HTTP transport.

The same validation limits apply as for TCP publishes:

| Limit | Default | Config Key |
|-------|---------|------------|
| Max headers per message | 64 | `queue.max_headers_per_message` |
| Max header key size | 256 bytes | `queue.max_header_key_bytes` |
| Max header value size | 8,192 bytes | `queue.max_header_value_bytes` |
| Max total header bytes | 32,768 bytes | `queue.max_header_bytes_total` |

Headers with the reserved prefix `_fafb.` (after stripping `X-Fafb-`) are rejected with `400 Bad Request`. This prefix is reserved for server-set system headers (e.g., DLQ provenance metadata).

**Permission required:** `Publish`

**Response (200 OK):**

```json
{
  "offset": 0,
  "message_id": 1,
  "timestamp_millis": 1700000000000
}
```

| Field | Type | Description |
|-------|------|-------------|
| `offset` | integer | Sequential offset assigned to the message within the topic |
| `message_id` | integer | Unique message identifier (used for ack tracking in TCP) |
| `timestamp_millis` | integer | Server-assigned publish timestamp (milliseconds since Unix epoch) |

**Error responses:**

| Status | When |
|--------|------|
| `401 Unauthorized` | Missing/invalid API key, or namespace mismatch |
| `403 Forbidden` | API key lacks `Publish` permission |
| `404 Not Found` | Namespace not configured |
| `503 Service Unavailable` | Resource limit reached: queue full (backpressure), namespace message/topic quota exceeded, or namespace disk quota exceeded |

---

### `GET /v1/{namespace}/{topic}/info`

Get queue statistics and subscriber information for a topic.

**Headers:**

| Header | Required | Value |
|--------|----------|-------|
| `Authorization` | Yes | `Bearer <api_key>` |

**Path parameters:**

| Parameter | Description |
|-----------|-------------|
| `namespace` | Target namespace |
| `topic` | Target topic name |

**Permission required:** `Query`

**Response (200 OK):**

```json
{
  "namespace": "my-namespace",
  "topic": "events",
  "depth": 42,
  "bytes_used": 10240,
  "base_offset": 0,
  "head_offset": 42,
  "subscriber_count": 3,
  "consumer_group_count": 1,
  "oldest_timestamp_millis": 1700000000000,
  "newest_timestamp_millis": 1700000042000
}
```

| Field | Type | Description |
|-------|------|-------------|
| `namespace` | string | Namespace name |
| `topic` | string | Topic name |
| `depth` | integer | Number of messages currently in the buffer |
| `bytes_used` | integer | Total payload bytes in the buffer |
| `base_offset` | integer | Lowest retained offset (after compaction) |
| `head_offset` | integer | Next offset to be assigned |
| `subscriber_count` | integer | Total connected subscribers (individual + group members). **Note:** reflects TCP subscribers only. HTTP clients do not maintain persistent subscriptions, so this will be zero in an HTTP-only deployment. |
| `consumer_group_count` | integer | Number of consumer groups (TCP only, see note above) |
| `oldest_timestamp_millis` | integer or null | Timestamp of the oldest buffered message (millis since epoch). `null` when the topic is empty. |
| `newest_timestamp_millis` | integer or null | Timestamp of the newest buffered message (millis since epoch). `null` when the topic is empty. |

**Error responses:**

| Status | When |
|--------|------|
| `401 Unauthorized` | Missing/invalid API key, or namespace mismatch |
| `403 Forbidden` | API key lacks `Query` permission |
| `404 Not Found` | Namespace not configured, or topic does not exist |

---

### `GET /v1/{namespace}/topics`

List all topics that have published data in a namespace.

**Headers:**

| Header | Required | Value |
|--------|----------|-------|
| `Authorization` | Yes | `Bearer <api_key>` |

**Path parameters:**

| Parameter | Description |
|-----------|-------------|
| `namespace` | Target namespace |

**Permission required:** `Query`

**Response (200 OK):**

```json
{
  "namespace": "my-namespace",
  "topics": ["events", "commands", "notifications"]
}
```

| Field | Type | Description |
|-------|------|-------------|
| `namespace` | string | Namespace name |
| `topics` | array of strings | Topic names with published data |

**Error responses:**

| Status | When |
|--------|------|
| `401 Unauthorized` | Missing/invalid API key, or namespace mismatch |
| `403 Forbidden` | API key lacks `Query` permission |
| `404 Not Found` | Namespace not configured |

---

### `GET /v1/{namespace}/{topic}/fetch`

Pull a batch of messages from a topic. Stateless — does not create subscriptions or track consumer state. The client manages its own offset progression.

**Note:** The topic must be a concrete topic name. Wildcard patterns (`*`, `>`) are not supported for fetch — use TCP Subscribe for wildcard consumption, or fetch each topic individually via the `GET /v1/{namespace}/topics` endpoint to discover topics.

**Headers:**

| Header | Required | Value |
|--------|----------|-------|
| `Authorization` | Yes | `Bearer <api_key>` |

**Path parameters:**

| Parameter | Description |
|-----------|-------------|
| `namespace` | Target namespace (must match API key's namespace) |
| `topic` | Target topic name |

**Query parameters:**

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `offset` | i64 | One of `offset` or `timestamp` | — | Starting offset (inclusive). Mutually exclusive with `timestamp`. Negative values are relative to head: `-1` = last message, `-N` = N messages from head. Resolved server-side before fetching. |
| `timestamp` | u64 | One of `offset` or `timestamp` | — | Milliseconds since epoch. Fetch from first message at or after this timestamp. Mutually exclusive with `offset`. |
| `max_count` | u32 | No | 100 | Maximum messages to return. Must be 1–10,000. Server may return fewer due to response size limits. |

**Permission required:** `Fetch`

**Response (200 OK):**

```json
{
  "topic": "events",
  "next_offset": 15,
  "head_offset": 42,
  "messages": [
    {
      "offset": 10,
      "timestamp_millis": 1700000000000,
      "headers": [
        { "key": "content-type", "value": "application/json" },
        { "key": "correlation-id", "value": "req-12345" }
      ],
      "payload_base64": "aGVsbG8gd29ybGQ="
    },
    {
      "offset": 11,
      "timestamp_millis": 1700000000100,
      "payload_base64": "Ynl0ZXM="
    }
  ]
}
```

| Field | Type | Description |
|-------|------|-------------|
| `topic` | string | Echoed topic name |
| `next_offset` | integer | Offset for the next fetch call. If messages returned: last offset + 1. If empty (caught up): same as `head_offset`. |
| `head_offset` | integer | Topic's current head offset (next offset to be assigned). Use `next_offset < head_offset` to check if more messages are available. |
| `messages` | array | Fetched messages. May be empty if the topic doesn't exist or the offset is at head. |
| `messages[].offset` | integer | Message offset within the topic |
| `messages[].timestamp_millis` | integer | Publish timestamp (milliseconds since epoch) |
| `messages[].ttl_ms` | integer | Original TTL in milliseconds. `0` = no expiry. Compute remaining TTL: `(timestamp_millis + ttl_ms) - now_ms`. |
| `messages[].headers` | array (optional) | Message headers as an ordered array of key-value objects. Omitted when the message has no headers (not present as an empty array). See below. |
| `messages[].payload_base64` | string | **Base64-encoded** message payload. Decode before use. |
| `warning` | string (optional) | Present when the requested offset was deleted by retention and clamped to the oldest available. |

**Header format in JSON responses:**

Headers are returned as an ordered array of objects to preserve insertion order and allow duplicate keys (matching `Vec<Header>` semantics):

```json
"headers": [
  { "key": "content-type", "value": "application/json" },
  { "key": "trace-id", "value": "abc123" },
  { "key": "binary-data", "value_base64": "AQID" }
]
```

| Field | Type | Description |
|-------|------|-------------|
| `key` | string | Header key (UTF-8 string). |
| `value` | string | Header value as a UTF-8 string. Present when the value is valid UTF-8. |
| `value_base64` | string | Header value as base64-encoded bytes. Present instead of `value` when the value contains non-UTF-8 binary data. |

Each header object has either `value` or `value_base64`, never both. DLQ system headers (`_fafb.dlq.*`) are included when fetching from DLQ topics.

> **Important:** Payloads are base64-encoded in the JSON response. SDK developers must decode `payload_base64` before returning the payload to users. This encoding is necessary because message payloads are arbitrary binary data.

**Pagination:** To drain a topic, start with `offset=0` and chain requests using `next_offset` from each response:

```bash
# First request
curl ".../fetch?offset=0&max_count=100"  # → next_offset=100, head_offset=500

# Continue until next_offset >= head_offset
curl ".../fetch?offset=100&max_count=100"  # → next_offset=200, head_offset=500
# ...
curl ".../fetch?offset=400&max_count=100"  # → next_offset=500, head_offset=500 (caught up)
```

**Negative offsets (tail):** Fetch the last N messages without knowing the head offset:

```bash
# Last message
curl ".../fetch?offset=-1&max_count=1"

# Last 10 messages
curl ".../fetch?offset=-10&max_count=10"
```

Negative offsets are resolved server-side: `offset = -N` becomes `head_offset - N`. If the resolved offset is below the oldest retained offset, it is clamped and a `warning` field is included in the response. If the topic is empty, returns an empty response.

**Response size limit:** Configurable via `http.max_fetch_response_bytes` (default 4MB). This is larger than the TCP frame limit (1MB), so messages that return `MessageTooLarge` over TCP may be fetchable via HTTP.

**Empty / nonexistent topics:** Return `200` with an empty `messages` array, not `404`. Topics auto-create on publish, so "not found" and "empty" are indistinguishable by design.

**Error responses:**

| Status | When |
|--------|------|
| `400 Bad Request` | Missing both `offset` and `timestamp`, both provided, `max_count` is 0 or > 10,000, or invalid parameter values |
| `401 Unauthorized` | Missing/invalid API key, or namespace mismatch |
| `403 Forbidden` | API key lacks `Fetch` permission |
| `404 Not Found` | Namespace not configured |
| `413 Payload Too Large` | Message at requested offset exceeds `max_fetch_response_bytes` budget. Error includes the stuck offset and message size. Skip with `offset + 1` or increase the budget. |

---

## Error Response Format

All error responses use a consistent JSON body:

```json
{
  "error": {
    "code": 1001,
    "name": "AUTH_FAILED",
    "message": "Invalid or missing API key"
  }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `code` | integer | Numeric error code (shared with TCP protocol) |
| `name` | string | Machine-readable error name |
| `message` | string | Human-readable error description |

### Error Codes

Error codes are shared with the TCP protocol. See [protocol.md](protocol.md#shared-error-codes) for the full table.

| Code | Name | HTTP Status |
|------|------|-------------|
| `0x0001` | `AUTH_FAILED` | 401 |
| `0x0002` | `PERMISSION_DENIED` | 403 |
| `0x0003` | `NAMESPACE_NOT_FOUND` | 404 |
| `0x0004` | `TOPIC_NOT_FOUND` | 404 |
| `0x0008` | `NOT_AUTHENTICATED` | 401 |
| `0x0009` | `INTERNAL_ERROR` | 500 |
| `0x000A` | `RESOURCE_EXHAUSTED` | 503 |
| `0x000E` | `MESSAGE_TOO_LARGE` | 413 |
| `0x000F` | `INVALID_TOPIC` | 400 |

## Admin Endpoints

Admin endpoints live at `/admin/*` (outside `/v1/*`) and are not gated by the `require_initialized` middleware — they work during startup and drain.

### `POST /admin/drain`

Trigger a graceful drain via HTTP. This initiates the same shutdown sequence as SIGTERM, but allows orchestration tools to control the process programmatically.

**Authentication**: `Authorization: Bearer <admin_api_key>` (from `[deploy].admin_api_key` config).

**Request body** (optional JSON):

```json
{
  "reason": "upgrade",
  "timeout_secs": 30
}
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `reason` | string | Config value (`"upgrade"`) | Reason code for GoingAway frames |
| `timeout_secs` | integer | Config value (30) | Override drain timeout for this drain |

**Responses:**

| Status | Body | When |
|--------|------|------|
| `200 OK` | `{ "status": "draining", "active_connections": 4523, "deadline": "2026-03-21T15:30:00Z" }` | Drain initiated |
| `409 Conflict` | `{ "status": "already_draining", ... }` | Server is already draining |
| `403 Forbidden` | `{ "error": "admin API key not configured or invalid" }` | Missing/wrong admin key |

**Example:**

```bash
# Trigger drain with custom timeout
curl -X POST http://localhost:8080/admin/drain \
  -H "Authorization: Bearer your-admin-key-here-min-24" \
  -H "Content-Type: application/json" \
  -d '{"reason": "upgrade", "timeout_secs": 60}'

# Poll readiness until 503
while curl -sf http://localhost:8080/health/ready > /dev/null; do sleep 1; done

# Server is drained — safe to kill
kill $SERVER_PID
```

---

### `GET /admin/drain/status`

Return current drain progress from server lifecycle atomics.

**Authentication**: `Authorization: Bearer <admin_api_key>`.

**Response (200 OK):**

```json
{
  "state": "draining",
  "active_connections": 127,
  "drain_started_at": "2026-03-21T15:29:30Z",
  "drain_deadline": "2026-03-21T15:30:00Z",
  "connections_drained": 4396
}
```

| Field | Type | Description |
|-------|------|-------------|
| `state` | string | `"running"`, `"draining"`, or `"shutting_down"` |
| `active_connections` | integer | Current number of TCP connections |
| `drain_started_at` | string or null | ISO 8601 timestamp when drain began. `null` if not draining. |
| `drain_deadline` | string or null | ISO 8601 timestamp of expected drain deadline. `null` if not draining. |
| `connections_drained` | integer | Connections closed since drain started (0 if not draining) |

---

### `PUT /v1/admin/log-level`

Dynamically change the server's log level filter at runtime without a restart.

**Authentication**: Required. Any valid API key with `Query` permission.

**Request body**: Plain text — a `RUST_LOG`-style filter string.

```
info,rust_fafb_q::storage::fs=debug,rust_fafb_q::auth=debug
```

**Response**:

| Status | Meaning |
|--------|---------|
| `200 OK` | Filter applied. Body echoes the new filter string. |
| `400 Bad Request` | Invalid filter syntax. Body contains the parse error. |
| `401 Unauthorized` | Missing or invalid API key. |
| `403 Forbidden` | API key lacks `Query` permission. |
| `500 Internal Server Error` | Filter reload failed (unexpected). |

**Example**:

```bash
curl -X PUT http://localhost:8080/v1/admin/log-level \
  -H "Authorization: Bearer your-api-key" \
  -d "debug,rust_fafb_q::storage=trace"
```

### `GET /v1/admin/log-level`

Return the current active log level filter.

**Authentication**: Required. Any valid API key with `Query` permission.

**Response**: `200 OK` with the current filter string as plain text.

```bash
curl http://localhost:8080/v1/admin/log-level \
  -H "Authorization: Bearer your-api-key"
# => info,rust_fafb_q::storage=debug
```

## Future Considerations

- **WebSocket upgrade for subscriptions**: A future version may support `GET /v1/{namespace}/{topic}/subscribe` with WebSocket upgrade, enabling push-based subscription delivery over HTTP. This would mirror the TCP Subscribe/Deliver/Ack semantics. Not planned for v1 — use the TCP interface for subscriptions.
- **Batch publish**: `POST /v1/{namespace}/{topic}/publish/batch` for submitting multiple messages in a single HTTP request. Under consideration for v2.
