# API reference

# HTTP API

Rosie is an HTTP mailbox. A caller puts one job and a caller takes one job. The same caller can put or take several jobs on `POST /put/batch` and `POST /take/batch`. A leased take is finished at `POST /ack` or `POST /release`. A matching put can notify a webhook registered at `POST /webhooks`. The JSON field `queue` is the address of that queue. This page is the reference for those calls. The narrative is in the README.

The deployed calls are `https://mailbox.strand3.com/put` and `https://mailbox.strand3.com/take`.

```
POST /put
POST /put/batch
POST /webhooks
POST /webhooks/list
POST /webhooks/delete
POST /webhooks/deliveries
POST /take
POST /take/batch
POST /ack
POST /release
GET /health
GET /openapi.json
```

`GET /openapi.json` returns this API as OpenAPI. The committed copy is [`/openapi.json`](/openapi.json). The same document is served at https://rosie.strand3.com/openapi.json, and https://rosie.strand3.com links /api/ and /specs/.

These calls send and receive `application/json` except where a status below says the body is empty. Rosie ignores unknown JSON fields. `queue` is required on these calls and is a string of 1 through 256 UTF-8 bytes. Rosie stores only the SHA-256 of that string. A job whose put returned `200` stays queued until a plain take or an ack removes it, including after a restart of the server or the database. A live lease stays with the job across that restart.

## Fields

| Field | Who sets it | On put | On take filter | Meaning |
|---|---|---|---|---|
| `queue` | client | required | no | Address of the queue. Non-empty string, at most 256 UTF-8 bytes. The same string is the same queue. Rosie stores only the SHA-256 and omits the raw value from the database and from responses. |
| `job_id` | client | required | optional | Correlation token. Non-empty string, at most 128 UTF-8 bytes. Unique per queue while queued. |
| `job_type` | client | optional | optional | String of 1 through 128 UTF-8 bytes. On take, no `*` means an exact match. One trailing `*` is a prefix (`resize.*` matches `resize.thumb`). A bare `*` matches every job, the same as omitting the field. Any other `*` is `400`. A put that contains `*` is `400` and queues nothing. |
| `job_desc` | client | optional | no | Short description, at most 1024 UTF-8 bytes. Returned with the job. Never selects a job. |
| `job_priority` | client | optional | no | Integer from `-9007199254740991` through `9007199254740991`. Default `0`. Higher is taken first. Equal values are FIFO. Never selects a job. |
| `enqueued_at` | server | — | no | UTC time the job was queued, RFC 3339 with a `Z` suffix. A replacement gets a new value. |
| `content_type` | client | required | no | Description of the payload. Non-empty string, at most 128 UTF-8 bytes, kept as sent. Returned with the job. Never selects a job. Rosie does not interpret the bytes from this value. |
| `payload` | client | required | no | Standard base64 (`A-Za-z0-9+/` with padding, no whitespace) of the raw bytes. Rosie stores the decoded bytes and encodes them again on take. |
| `job_tags` | client | optional | optional | Array of 1 through 32 distinct strings, each 1 through 128 UTF-8 bytes, kept as sent. On put, stored with the job. On take, a filter. Omitted is not a constraint. An empty array is `400`. Tags cannot be changed after put. |
| `tag_match` | client | no | optional | Take only. `all` (default) or `any`, and only together with `job_tags`. `all` requires every tag. `any` requires one. Sending it without `job_tags`, or any other value, is `400`. |
| `replace` | client | optional | no | Boolean, default `false`. `true` replaces a job with the same `job_id` that is still queued. Any other JSON type, including `null`, is `400`. |
| `replaced` | server | — | no | On every successful put. `true` when this put removed a queued job with the same `job_id`. `false` when the job is new, including when `replace` is `true` and nothing was queued. |
| `lease_secs` | client | no | take only | Optional integer from 1 through `ROSIE_MAX_LEASE_SECS` (default 3600). Omitted, the take deletes the job. Present, the job stays hidden until `leased_until`. Not an integer, below 1, or above the cap is `400`. |
| `lease_id` | server | — | no | On a leased take only. 32 lowercase hex characters. A capability for that delivery. Rosie stores only its SHA-256 and does not log this value. The placeholder `0123456789abcdef0123456789abcdef` is not a real lease id. |
| `leased_until` | server | — | no | On a leased take only. UTC time the lease ends, RFC 3339 with a `Z` suffix. |
| `attempts` | server | — | no | On a leased take only. How many leased deliveries this queued job has had, starting at 1. |

## Put

`POST /put`

```json
{
  "queue": "alpha",
  "job_id": "42",
  "job_type": "resize",
  "job_desc": "notes.json",
  "job_priority": 0,
  "content_type": "application/json",
  "payload": "eyJoZWxsbyI6IndvcmxkIn0="
}
```

`job_type`, `job_desc`, `job_priority`, `job_tags`, and `replace` may be omitted. Omitted `replace` is `false`. `job_tags` is a set: take returns it sorted by byte order, and only when the put included it. There is no call that changes tags later.

Success is `200`:

```json
{ "job_id": "42", "enqueued_at": "2026-10-08T21:00:00Z", "replaced": false }
```

`replaced` is always present. It is `false` on a new job.

A second put of a `job_id` that is still queued on that queue is `409` when `replace` is omitted or `false`. The original job stays, including when the new body matches it. A `job_id` under a live lease is `409` with or without `replace`, on a single put and on a batch put, and the leased job is unchanged. A lease that has expired counts as queued, so `replace` may remove it. After that replace, an ack with the old `lease_id` is `409`. After a successful plain take or ack, that `job_id` may be put again. The same `job_id` on two queues is two jobs.

When `replace` is `true` and that `job_id` is still queued, the put is `200` and `replaced` is `true`. Rosie removes the queued job and queues this put in its place. Every stored field comes from this put. An omitted `job_type`, `job_desc`, `job_priority`, or `job_tags` does not keep the previous value (`job_priority` becomes `0`, and the replacement has no tags). The replacement gets a new `enqueued_at` and goes behind other jobs of equal `job_priority`.

When `replace` is `true` and no job with that `job_id` is queued, including after a take, Rosie queues the job and `replaced` is `false`. A job that has already been taken is not restored.

## Take

`POST /take` waits until one matching job is queued or the server hold elapses. One waiter receives the job. The body is a JSON object. An unfiltered take is `{"queue":"..."}`.

| Body | Waits for |
|---|---|
| `{"queue":"..."}` | The next job on that queue. |
| `{ "queue": "alpha", "job_type": "resize" }` | A job whose `job_type` is `resize`. |
| `{ "queue": "alpha", "job_type": "resize.*" }` | A job whose `job_type` starts with `resize.`. `resize` and `crop` do not match. |
| `{ "queue": "alpha", "job_id": "42" }` | The job whose `job_id` is `42`. |
| `{ "queue": "alpha", "job_id": "42", "job_type": "resize" }` | That `job_id` and that `job_type`. |
| `{ "queue": "alpha", "job_tags": ["red", "large"] }` | A job that carries both tags. `tag_match` defaults to `all`. |
| `{ "queue": "alpha", "job_tags": ["red", "blue"], "tag_match": "any" }` | A job that carries `red` or `blue`. |

A job with no tags does not match a take that sends `job_tags`. A take that omits `job_tags` matches tagged and untagged jobs. `job_tags` is combined with `job_id` and `job_type` by AND.

An omitted `job_id` or `job_type` is not a constraint. A field that is sent must match. A present `job_id` or `job_type` must be a non-empty string of at most 128 UTF-8 bytes.

A take `job_type` with no `*` matches the whole string. A single trailing `*` matches that prefix, byte for byte and case-sensitively. `%`, `_`, and `\` in the prefix match only themselves, so `scrape_*` does not match `scrapeXwork` and `100%*` matches a `100%` prefix only. A job put without `job_type` does not match a prefix. `{"job_type":"*"}` waits for the same jobs as a take that omits `job_type`, including untyped jobs. A `*` that is not the final byte, or more than one `*` (`re*size`, `*.thumb`, `a**`), is `400`. `*` is reserved on put: a `job_type` that contains it is `400` and queues nothing.

When several jobs match, the highest `job_priority` is taken, then the oldest. A `job_id` matches at most one queued job.

Success is `200`. `job_type`, `job_desc`, and `job_tags` are present only when the put set them. `job_tags` is sorted by byte order. `content_type` and `payload` are always present.

```json
{
  "job_id": "42",
  "job_type": "resize",
  "job_desc": "notes.json",
  "job_priority": 0,
  "enqueued_at": "2026-10-08T21:00:00Z",
  "content_type": "application/json",
  "payload": "eyJoZWxsbyI6IndvcmxkIn0="
}
```

A take that finds nothing before the hold elapses is `204` with an empty body. A plain take deletes the job, and that queued instance is not returned again.

`lease_secs` is optional. With it, the take hides each claimed job until `leased_until` instead of deleting it, increments `attempts`, and returns a new `lease_id` for that delivery. A plain take omits `lease_id`, `leased_until`, and `attempts`. While the lease is live, no take returns that job. After `leased_until`, the job is queued again with its original `job_priority`, `enqueued_at`, and queue order. A waiting take finds it on the next re-check, about once a second, without a new put. A leased take of that job reports `attempts` one higher. A worker that leases must be ready for the same job more than once.

## Batch put

`POST /put/batch` queues many jobs on one queue, or none of them. The body is one `queue` and a `jobs` array. Each item uses the put fields above, including `replace` and `job_tags`. An item whose `job_type` contains `*` is `400` with that item's `index`, and nothing from the batch is queued. A `queue` inside an item is ignored. `jobs` must hold 1 through `ROSIE_MAX_BATCH` items (default 100). An empty array, a missing array, or a longer array is `400` with no `index`.

```json
{
  "queue": "alpha",
  "jobs": [
    { "job_id": "a", "content_type": "text/plain", "payload": "YQ==" },
    { "job_id": "b", "content_type": "text/plain", "payload": "Yg==", "replace": true }
  ]
}
```

The whole batch is one transaction. Items are inserted in array order, so equal `job_priority` is taken in that order. Success is `200`. `jobs` lists one result per item, in request order. Every item shares one `enqueued_at`. `replaced` is per item and follows the single-put rule.

```json
{
  "jobs": [
    { "job_id": "a", "enqueued_at": "2026-10-08T21:00:00Z", "replaced": false },
    { "job_id": "b", "enqueued_at": "2026-10-08T21:00:00Z", "replaced": true }
  ]
}
```

If any item is invalid, the status is `400` or `413` and nothing is queued. The body is `{ "error": "...", "index": N }` for the first failing item, with `index` counting from 0. `index` is omitted when the request itself is invalid, and when the sum of decoded payloads is over `ROSIE_MAX_BATCH_DECODED_BYTES` (default 8388608). Each item is still capped by `ROSIE_MAX_DECODED_BYTES`.

A `job_id` that is already queued and is not replaced, or a `job_id` repeated in the batch, is `409`. A repeat is `409` even when those items set `replace` to `true`. The body lists every conflicting id and nothing is queued:

```json
{ "error": "job id is already queued", "job_ids": ["a"] }
```

There is no partial-success status.

## Batch take

`POST /take/batch` uses the take filter fields plus a required integer `max_jobs` from 1 through `ROSIE_MAX_BATCH`. A missing, non-integer, or out-of-range `max_jobs` is `400` with no `index`. Filters AND together, the same way as `POST /take`. `job_type` follows those same rules: an exact string, a single trailing `*` prefix, or a bare `*`. `lease_secs` follows `POST /take`. One duration covers every returned job, and each job gets its own `lease_id`.

The server claims up to `max_jobs` matching jobs that are queued now. If at least one matches, it returns those jobs at once and does not wait to fill `max_jobs`. If none matches, it waits like a single take and returns `204` with an empty body when the hold elapses.

Success is `200`. `jobs` holds the taken jobs, each shaped like a single take, ordered by highest `job_priority`, then oldest.

```json
{
  "jobs": [
    {
      "job_id": "42",
      "job_type": "resize",
      "job_priority": 0,
      "enqueued_at": "2026-10-08T21:00:00Z",
      "content_type": "application/json",
      "payload": "eyJoZWxsbyI6IndvcmxkIn0="
    }
  ]
}
```

Concurrent batch takes and single takes on one queue each receive distinct jobs. A plain take delivers a job once. A leased take can deliver it again after release or expiry.

## Ack and release

`POST /ack` and `POST /release` take the same body. `queue`, `job_id`, and `lease_id` are required strings. An empty string, a non-string, or a missing field is `400`. `lease_id` is the value from the leased take. The placeholder below is not a real lease id.

```json
{
  "queue": "alpha",
  "job_id": "42",
  "lease_id": "0123456789abcdef0123456789abcdef"
}
```

When that queue holds `job_id` under that `lease_id`, the status is `200` and the body is `{ "job_id": "42" }`. `POST /ack` removes the job. `POST /release` clears the lease, keeps `attempts` and the original queue order, and wakes waiting takes so the job is visible at once. Otherwise the status is `409` and the body is `{ "error": "..." }`. That includes a wrong id, a lease that another take has already replaced, and a job that is already gone. An ack whose lease has expired still succeeds while no other take has claimed the job. Ack and release do not use a take permit.

## Webhooks

A webhook tells a receiver that a matching job was queued. The notice is not the job. The receiver takes the job itself. Rosie signs the notice and sends the receiver's API key. It does not follow redirects.

### Create

`POST /webhooks`

```json
{
  "queue": "alpha",
  "url": "https://bot.example/hook",
  "job_type": "gb.*",
  "api_key": "<receiver-key>",
  "api_key_header": "X-Api-Key"
}
```

`queue` follows the same rules as put. `url` must be an absolute `http` or `https` URL of at most 2048 bytes, with a host and no userinfo. `job_type` is required. It is an exact type, or one trailing `*` with at least one byte before it. Omitted, empty, a bare `*`, or any other `*` is `400`. `api_key` is required: 16 through 512 bytes of visible ASCII (no spaces). `api_key_header` is optional. When set, it is an HTTP token of at most 64 bytes and must not be `Host`, `Content-Type`, `Content-Length`, or start with `Rosie-` (any case). Otherwise `400`.

`200` returns `webhook_id`, `url`, `job_type`, `api_key_header` when it was sent, `created_at`, and `signing_secret`. `signing_secret` is `whsec_` plus 64 lowercase hex characters and is returned only here. The body never includes `api_key`. One more webhook than `ROSIE_MAX_WEBHOOKS_PER_QUEUE` (default 10) is `409` with `{ "error": "too many webhooks" }`. A bad body is `400` with `{ "error": "invalid webhook" }`.

A literal loopback, private, link-local, unspecified, or multicast host is `400` unless `ROSIE_WEBHOOK_ALLOW_PRIVATE` is true. That flag is for tests and local development and stays off in the deploy. A hostname is checked when the notice is sent. The flag is copied onto the webhook at create; a server delivers only webhooks created with the same value.

### List

`POST /webhooks/list` with `{ "queue": "alpha" }` returns `{ "webhooks": [ ... ] }`. Each entry has `webhook_id`, `url`, `job_type`, `api_key_header` when set, `created_at`, `pending`, and `dead`. It has no `api_key` and no `signing_secret`.

### Delete

`POST /webhooks/delete` with `{ "queue": "alpha", "webhook_id": "wh_example" }` returns `{ "webhook_id": "wh_example" }` and removes the subscription and its deliveries. Unknown on this queue, including an id that belongs to another queue, is `404` with an empty body.

### Deliveries

`POST /webhooks/deliveries` uses the same body as delete. `200` returns `{ "deliveries": [ ... ] }` with up to the 100 newest. Each has `delivery_id`, `job_id`, `state` (`pending`, `delivered`, or `dead`), `attempts`, `last_status`, `next_attempt_at`, and `created_at`. `last_status` is the last HTTP status, or `null` for a timeout, a connection error, or a refused target. No `api_key` and no `signing_secret`. The same missing-id rule as delete is `404` with an empty body. Delivered rows are removed after `ROSIE_WEBHOOK_DELIVERED_RETENTION_SECS` (default 3600). Dead rows are removed after `ROSIE_WEBHOOK_DEAD_RETENTION_SECS` (default 604800).

### Notice

A committed put, each committed batch item, and a replacement record one delivery per matching subscription in that transaction. The OpenAPI `webhooks` entry `job.put` in [`/openapi.json`](/openapi.json) describes this notice. Matching is byte for byte and case sensitive. A trailing `*` is a prefix. A job with no `job_type` matches nothing. Take, a failed put, and a rolled-back batch record nothing. The job stays queued until a take returns it.

`POST` body:

```json
{
  "event": "job.put",
  "webhook_id": "wh_example",
  "delivery_id": "dl_example",
  "queue_ref": "3f9a1c0be27d",
  "job_id": "42",
  "job_type": "gb.handoff",
  "job_priority": 0,
  "enqueued_at": "2026-10-26T21:30:00Z",
  "replaced": false
}
```

There is no `queue`, `payload`, `content_type`, `job_desc`, or `job_tags`. `queue_ref` is the first 12 hex characters of the queue's SHA-256.

Headers on every attempt:

| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `Authorization` | `Bearer <api_key>` when `api_key_header` was omitted |
| `api_key_header` | The raw `api_key`, with no `Bearer` prefix, when that field was set |
| `Rosie-Webhook-Id` | The webhook id |
| `Rosie-Delivery-Id` | The delivery id, stable across retries |
| `Rosie-Signature` | `t=<unix seconds>,v1=<hex>` |

`v1` is hex HMAC-SHA256 of the ASCII bytes of `"<t>."` followed by the exact body bytes. The key is the 32 bytes decoded from `signing_secret` after `whsec_`. `t` is the time of that attempt. To verify: decode the secret, parse `t` and `v1`, recompute the HMAC, compare in constant time, then reject when `t` differs from now by more than the tolerance (5 minutes is the recommendation). A difference equal to the tolerance is still valid. `rosie_client::verify_webhook_signature` does this.

Any `2xx` within `ROSIE_WEBHOOK_TIMEOUT_SECS` (default 10) succeeds. DNS and the HTTP request share that one deadline. A `3xx`, a timeout, a connection error, or a refused address fails the attempt. Rosie does not follow redirects and connects only to an address it already checked. After failed attempt `n`, the next wait is `min(ROSIE_WEBHOOK_RETRY_BASE_SECS * 2^(n-1), ROSIE_WEBHOOK_RETRY_MAX_SECS)` seconds plus up to `ROSIE_WEBHOOK_RETRY_JITTER_PERCENT` percent. After `ROSIE_WEBHOOK_MAX_ATTEMPTS` (default 8) the row is `dead` and is not sent again. The same `Rosie-Delivery-Id` can be delivered more than once. A duplicate notice is not a second job; take returns the job once.

Order across jobs and retries is not guaranteed. Several server processes will not send the same attempt at the same time. A delivery recorded before a restart is sent after it.

## Test queues

An external test uses its own queue value for that run. The value is at least 16 random bytes from a cryptographic RNG, for example 22 or more base64url characters. No prefix is required.

The test never puts or takes on a production queue value, and it never reads one from a deploy secret. It drains the queues it created, taking every job it put, before it finishes. A job left behind stays queued until a take returns it. The test keeps its queue value private, out of logs, issues, and pull requests.

Test jobs may use any `job_type`. Isolation comes from the queue value.

## Health

`GET /health` returns `200` and `{"status":"ok"}` when the database answers `SELECT 1` within 2 seconds. It returns `503` and `{ "error": "..." }` when the database does not answer. The body has no configuration and no secrets. This route is not authenticated.

## Statuses

| Status | When | Body |
|---|---|---|
| `200` | Put queued the job, take delivered one, a batch call queued or delivered its jobs, ack or release matched the lease, or a webhook call succeeded. | JSON. Put returns `job_id`, `enqueued_at`, and `replaced`. Take returns the stored envelope, plus `lease_id`, `leased_until`, and `attempts` when `lease_secs` was sent. A batch call returns `jobs`. Ack and release return `job_id`. Webhook responses are in [Webhooks](#webhooks). |
| `204` | Take, or batch take, found nothing before the hold elapsed. A live lease is hidden, so it counts as nothing found. | Empty. |
| `400` | The envelope is invalid. Missing, empty, non-string, or over-long `queue`, missing or bad `job_id`, missing, empty, non-string, or over-long `content_type`, bad base64 `payload`, an empty filter field, a `job_type` longer than 128 bytes, a `*` anywhere in a put's `job_type`, a take `job_type` whose `*` is not a single trailing star, a `job_priority` outside `-9007199254740991` through `9007199254740991`, a bad `job_tags` array, a `tag_match` that is unknown or sent without `job_tags`, a `replace` value that is not a JSON boolean, a `lease_secs` that is not an integer from 1 through `ROSIE_MAX_LEASE_SECS`, a missing, empty, or non-string `lease_id` on ack or release, a batch whose `jobs` or `max_jobs` is missing or out of range, or a webhook URL, `job_type`, `api_key`, or `api_key_header` that fails the rules in [Webhooks](#webhooks). A bad batch item includes `index`. | `{ "error": "..." }` |
| `404` | `GET` other than `/health` and `/openapi.json`, including `GET` on `/put`, `/take`, `/put/batch`, `/take/batch`, `/ack`, `/release`, or a webhook route, any other path, or `POST /webhooks/delete` and `POST /webhooks/deliveries` when this queue has no webhook with that id. | Empty. |
| `409` | That queue already has this `job_id` queued and `replace` is not `true`, or the job is under a live lease (with or without `replace`). A batch put also returns `409` when a `job_id` is repeated in the batch or is under a live lease, and the body includes `job_ids`. Nothing from that batch is queued. Ack or release returns `409` when that `lease_id` does not hold the job. A webhook create over `ROSIE_MAX_WEBHOOKS_PER_QUEUE` is `409` with `{ "error": "too many webhooks" }`. | `{ "error": "..." }` |
| `413` | Decoded `payload` is larger than `ROSIE_MAX_DECODED_BYTES` (default 1048576), or a batch's decoded payloads sum to more than `ROSIE_MAX_BATCH_DECODED_BYTES`. The job or batch is not queued. An oversized item includes `index`. The batch total omits `index`. | `{ "error": "..." }` |
| `503` | The database is unreachable, or the server cannot hold another waiting take. | `{ "error": "..." }` |

`ROSIE_MAX_BATCH` defaults to 100 and caps both `jobs` and `max_jobs`. `ROSIE_MAX_BATCH_DECODED_BYTES` defaults to 8388608 and caps the sum of decoded payloads in one batch put. `POST /put/batch` accepts a larger request body than the other routes: two times that cap, plus 64 KiB per allowed item. The other routes keep the single-job body limit.

Every server setting, with its default, is listed in [Configuration](https://gitea.galiath.net/Greg-Hewett/rosie/src/branch/main/README.md#configuration) in the README.

