# Courier API

Courier is a delivery queue for the Telegram Bot API. Your app hands Courier a Bot API call
(`sendMessage`, `copyMessage`, `sendPhoto`, `editMessageText`, `deleteMessage`, anything) and
gets an id back in about a millisecond. Courier then delivers it at the pace Telegram allows,
retries transient failures, backs off on `429`, spaces messages to the same chat, deletes
messages on a timer, deduplicates, and can call you back with the outcome.

- Base URL: `https://courier.imagelens.xyz` (production) · `http://127.0.0.1:5072` (local)
- Auth: `Authorization: Bearer cr_…` (or header `x-api-key`) for `/v1/*`; `x-admin-token` for `/v1/admin/*`
- All bodies and responses are JSON. Every response carries `ok: true|false`.
- Errors: `{ "ok": false, "error": "human readable reason" }` with a 4xx/5xx status.

## 60-second integration

```bash
curl -X POST https://courier.imagelens.xyz/v1/send \
  -H "Authorization: Bearer cr_XXXX" -H "content-type: application/json" \
  -d '{"bot":"flixmini","method":"sendMessage","params":{"chat_id":993030461,"text":"hello"}}'
# → 202 {"ok":true,"id":"flixmini.6b1e…","status":"queued"}
```

That is the whole integration. `method` is any Bot API method and `params` is exactly what
you would POST to `api.telegram.org` — Courier passes it through untouched, so `reply_markup`,
`parse_mode`, `caption`, `entities`, file ids, everything works the same.

PHP (no SDK needed):

```php
$ch = curl_init('https://courier.imagelens.xyz/v1/send');
curl_setopt_array($ch, [
  CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 3,
  CURLOPT_HTTPHEADER => ['content-type: application/json', 'authorization: Bearer '.$key],
  CURLOPT_POSTFIELDS => json_encode(['bot' => 'flixmini', 'method' => 'copyMessage',
    'params' => ['chat_id' => $chat, 'from_chat_id' => $channel, 'message_id' => $mid, 'caption' => $cap],
    'priority' => 7, 'delete_after' => 3600, 'dedupe' => "file:$chat:$mid"]),
]);
$res = json_decode(curl_exec($ch), true);
```

Node:

```js
const r = await fetch('https://courier.imagelens.xyz/v1/send', {
  method: 'POST',
  headers: { authorization: `Bearer ${KEY}`, 'content-type': 'application/json' },
  body: JSON.stringify({ bot: 'flixmini', method: 'sendMessage', params: { chat_id, text } }),
})
const { id } = await r.json()
```

## Send one message — `POST /v1/send`

| field | type | required | meaning |
|---|---|---|---|
| `bot` | string | yes | Bot id as registered in Courier (not the token) |
| `method` | string | yes | Bot API method name, e.g. `sendMessage`, `copyMessage`, `sendDocument` |
| `params` | object | yes | The method's parameters, exactly as Telegram documents them |
| `priority` | 1–10 | no | Higher goes first. Default `5`. Use 8–10 for replies to a user action, 1–3 for broadcasts |
| `delay` | seconds | no | Wait this long before the first attempt |
| `ttl` | seconds | no | If the message is still queued after this, drop it silently (reported as `expired`). Good for "your file is ready" pings that are pointless an hour later |
| `delete_after` | seconds | no | Delete the sent message after this many seconds (Courier queues the `deleteMessage` for you) |
| `dedupe` | string | no | Idempotency key. Same `bot` + `dedupe` while the earlier job still exists (up to 6 h after completion) → `200 {"status":"duplicate"}` and nothing is sent |
| `callback` | url | no | Courier POSTs the outcome here (see *Callbacks*) |
| `meta` | object | no | Anything you want echoed back in status and callbacks (order id, user id…). Never sent to Telegram |
| `attempts` | 1–10 | no | Max attempts on transient failure. Default `5`, exponential backoff from 1.5 s |

Responses

- `202 { ok, id, status: "queued" }` — accepted.
- `200 { ok, id, status: "duplicate" }` — `dedupe` matched an existing job.
- `400` validation, `401` bad key, `403` key not allowed for this bot, `404` unknown bot.

## Send many — `POST /v1/send/batch`

```json
{
  "bot": "flixmini",
  "defaults": { "priority": 2, "ttl": 7200 },
  "messages": [
    { "method": "sendMessage", "params": { "chat_id": 1, "text": "Episode 5 is out" } },
    { "method": "sendMessage", "params": { "chat_id": 2, "text": "Episode 5 is out" }, "dedupe": "ep5:2" }
  ]
}
```

Up to 1000 messages per call. `defaults` is merged under every message. Response:
`202 { ok, ids: [...], accepted, duplicates }`. One round trip to Redis for the whole batch.

## Job status — `GET /v1/jobs/:id`

```json
{ "ok": true, "job": {
  "id": "flixmini.6b1e…", "bot": "flixmini", "method": "sendMessage",
  "state": "completed",            // waiting | prioritized | delayed | active | completed | failed
  "priority": 5, "attempts": 1,
  "enqueued_at": "2026-09-26T10:00:00.000Z", "processed_at": "…", "finished_at": "…",
  "expires_at": null, "delete_after": 3600, "meta": { "order": 42 },
  "result": { "message_id": 123, "chat": { "id": 993030461 }, "…": "…" },   // Telegram's result when completed
  "error": "Bad Request: chat not found"                                         // when failed
}}
```

Completed jobs are kept 6 hours, failed jobs 3 days, then the id 404s.

## Cancel — `DELETE /v1/jobs/:id`

Removes a job that is still `waiting`, `prioritized` or `delayed`. `409` if it is already active or done.

## Stats — `GET /v1/stats?bot=flixmini&hours=24`

```json
{ "ok": true, "bot": "flixmini", "paused": false,
  "queue": { "waiting": 12, "prioritized": 3, "delayed": 40, "active": 8, "completed": 5120, "failed": 4, "backlog": 55 },
  "rate": { "per_minute": 1330, "peak_per_second": 28 },
  "unique_users": { "range": 812, "all_time": 61240 },      // distinct chat_ids (HyperLogLog, ±0.8 %)
  "totals": { "accepted": 5200, "sent": 5120, "failed": 4, "expired": 10, "retried": 22, "deleted": 800, "throttled": 15, "duplicate": 3 },
  "hourly": [ { "hour": "2026-09-25T11:00Z", "accepted": 200, "sent": 198, "…": 0 }, "…" ]
}
```

`hours` up to 192 (8 days). Counters are per bot, per UTC hour.

## Message history — `GET /v1/history?bot=flixmini`

The last 5,000 final outcomes per bot (sent, failed, expired, deleted), newest first. Filters: `status`, `method`, `chat_id`, `q` (matches text, error, id or message id), `limit` ≤ 500, `offset`. A key restricted to specific bots sees only its own messages; an all-bots key sees everything.

```json
{ "ok": true, "bot": "flixmini", "total": 4821, "offset": 0, "next": 100,
  "rows": [ { "id": "flixmini.6b1e…", "at": 1790450580000, "status": "sent", "method": "sendMessage",
              "chat": 993030461, "text": "Episode 5 is out", "app": "df152813a52e626d", "prio": 7,
              "ms": 9, "attempts": 1, "mid": 3063649, "meta": { "post_id": 1234 } } ] }
```

The same history, with the exact params sent to Telegram, is browsable in the admin panel under **Messages**.

## Recent failures — `GET /v1/failures?bot=flixmini&limit=50`

The last 200 permanent failures with Telegram's `code` and `description` (e.g. `403 Forbidden: bot was blocked by the user`). Use this to mark chats as unreachable on your side.

## Bots visible to your key — `GET /v1/bots`

## Callbacks

If `callback` is set, Courier POSTs JSON once the job reaches a final state:

```json
{ "id": "flixmini.6b1e…", "bot": "flixmini", "method": "sendMessage", "meta": { "order": 42 },
  "status": "sent",                        // sent | failed | expired
  "result": { "message_id": 123, "…": "…" },   // on sent
  "error": { "code": 403, "description": "Forbidden: bot was blocked by the user" }   // on failed
}
```

Callbacks time out after 5 s and are not retried. Poll `GET /v1/jobs/:id` if you need a guarantee.

## How delivery works

- **Per-bot rate**: each bot has its own queue and worker with a limiter (default 28 msg/s; Telegram's ceiling is ~30/s). Different bots never block each other.
- **Per-chat pacing**: a second `send*`/`copy*`/`forward*` to the same chat within one second is pushed back ~1 s instead of tripping a bot-wide `429`.
- **429 from Telegram**: the worker pauses for Telegram's `retry_after` and resumes automatically. The message is not lost and is not counted as a failure.
- **Transient errors** (network, 5xx): retried with exponential backoff up to `attempts`.
- **Permanent errors** (400 bad request, 403 blocked, 404): failed immediately, no retry, listed in `/v1/failures`.
- **Ordering**: within a bot, higher `priority` first; same priority is FIFO. Delayed and throttled messages re-enter with their original priority.
- **Persistence**: jobs live in Redis (AOF/RDB per the server config). A Courier restart drains nothing and loses nothing; in-flight jobs are retried.
- **Auto-delete**: `delete_after` enqueues a low-priority `deleteMessage` for the message Telegram returned. Telegram only allows deleting within 48 h.

## Admin — `x-admin-token`

| call | meaning |
|---|---|
| `GET /v1/admin/bots` | all bots (tokens never returned) |
| `POST /v1/admin/bots` `{ id, token, name?, rate? }` | register/rotate a bot. Courier verifies the token with `getMe`, seals it (AES-GCM) and starts the worker. `rate` caps messages/second (1–30) |
| `DELETE /v1/admin/bots/:id` | stop worker, wipe its queue, forget the token |
| `POST /v1/admin/bots/:id/pause` · `/resume` | stop/start delivery while keeping intake open (handy during incidents) |
| `POST /v1/admin/bots/:id/purge?state=waiting\|delayed\|failed\|completed` | drop jobs in a state |
| `GET /v1/admin/bots/:id/jobs?state=failed&limit=50` | peek at jobs |
| `GET /v1/admin/keys` | API keys (hashed; secrets are never stored) |
| `POST /v1/admin/keys` `{ name, bots?: ["flixmini"] }` | mint a key. Omit `bots` for all bots. Secret is returned once |
| `DELETE /v1/admin/keys/:id` | revoke |

`GET /health` is public: `{ ok, version, workers: ["flixmini"], uptime }`.

## Limits and sizes

- Request body ≤ 1 MB; `params` ≤ 64 KB per message. Send files by `file_id`/URL, not bytes — upload once with Telegram directly, then reuse the id.
- Batch ≤ 1000 messages.
- `dedupe` ≤ 200 chars. Dedupe window = as long as the previous job exists (queued, or ≤ 6 h after completion).

## Migrating from a "poll the database" worker

Replace the insert with `POST /v1/send` and delete the polling worker. Mapping from the old FlixDrama Mongo queue:

| old field | Courier |
|---|---|
| `method`, `parameters` | `method`, `params` |
| `priority` (1–10, desc) | `priority` (1–10) |
| `expires_in` (seconds) + `should_delete` | `delete_after` |
| `expires_in` as "don't send after" | `ttl` |
| status `pending → done/failed` | `GET /v1/jobs/:id` or `callback` |
