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 headerx-api-key) for/v1/*;x-admin-tokenfor/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
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
202 { ok, id, status: "queued" }— accepted.200 { ok, id, status: "duplicate" }—dedupematched an existing job.400validation,401bad key,403key not allowed for this bot,404unknown bot.
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
- 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-wide429. - 429 from Telegram: the worker pauses for Telegram's
retry_afterand 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
priorityfirst; 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_afterenqueues a low-prioritydeleteMessagefor 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 byfile_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 |