Courier · Telegram delivery queue · Admin · /health · raw markdown

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.

60-second integration

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):

$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:

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

Send many — POST /v1/send/batch

{
  "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

{ "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

{ "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.

{ "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:

{ "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

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

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