Etoki

Docs

Bot API

Bots are ordinary Etoki identities hosted by the bot relay. Each one gets a token and a small HTTP API: send to a subscriber, broadcast to all, or long-poll for replies.

Create a bot

Bots are created by chatting with the relay’s manager contact, the same way Telegram’s BotFather works:

in the app
1. In the app, add the bot manager contact: @bot
2. Send: /newbot
3. Reply with a display name, then a username ending in _bot
4. The manager replies with your API token — store it like a password
The token is a bearer credential in the URL path. Anyone holding it can post as your bot. Treat it like a password, keep it out of shell history and logs, and rotate it if it leaks.

Endpoints

MethodPathDescription
GET/v1/healthzLiveness. No token required.
GET/v1/bot/<token>/getmeUsername, display name, whether a webhook is set.
GET/v1/bot/<token>/connsConnection IDs of every subscriber.
GET/v1/bot/<token>/updatesLong-poll for inbound messages. Params: offset, timeout_secs.
POST/v1/bot/<token>/sendSend to one or many subscribers. Max 500 per call.
POST/v1/bot/<token>/broadcastSend to every subscriber. No fan-out limit.

Sending

Send to a single subscriber with conn_id:

send
curl -sk -X POST \
  "https://YOUR_SERVER_IP:5881/v1/bot/<token>/send" \
  -d '{"conn_id":4,"text":"hello from the API"}'

Or to several at once with conn_ids. Each recipient gets the message over its own end-to-end encrypted session, and the response reports success per connection:

send to many
curl -sk -X POST \
  "https://YOUR_SERVER_IP:5881/v1/bot/<token>/send" \
  -d '{"conn_ids":[4,7,11],"text":"hello everyone"}'

Use /broadcast to reach every subscriber without listing them. The recipient list is built server-side, so it is exempt from the 500-recipient cap that applies to /send:

broadcast
curl -sk -X POST \
  "https://YOUR_SERVER_IP:5881/v1/bot/<token>/broadcast" \
  -d '{"text":"announcement to all"}'

Priority

Both /send and /broadcast accept an optional priority. It controls how loudly the message arrives on the recipient’s phone:

ValueAlert levelOn the device
normal0An ordinary message. Respects mute and Do Not Disturb.
high1Loud one-shot alert on the alarm channel. Pierces ringer mute and per-chat mute.
critical2Full-screen alert plus a repeating alarm until opened or dismissed (60s cap).
emergency alerts
# high     — loud one-shot alert, pierces ringer mute
curl -sk -X POST \
  "https://YOUR_SERVER_IP:5881/v1/bot/<token>/broadcast" \
  -d '{"text":"EMERGENCY TEST","priority":"high"}'

# critical — full-screen repeating alarm until acknowledged
curl -sk -X POST \
  "https://YOUR_SERVER_IP:5881/v1/bot/<token>/broadcast" \
  -d '{"text":"site down","priority":"critical"}'

Omitting priority means normal. Any other value is rejected with 400. Recipients keep control: alerts only pierce mute for conversations where the user has left the per-chat “allow alerts” toggle on.

Rate limits

There are two separate limits, and both return 429:

  • General — 30 requests per second per bot, sustained and burst, across every endpoint. Exceeding it returns {"error":"rate limited"}. Normal sends are not budgeted, but they are not unlimited either.
  • Emergency — 5 per hour per bot, counting only calls with priority of high or critical. Exceeding it returns {"error":"emergency rate limited"}.
One API call counts as one emergency no matter how many recipients it reaches — a broadcast to a thousand subscribers costs the same single unit as a message to one. The budget is deliberately small: alarms that pierce mute are for real emergencies, not marketing.

Receiving

Long-poll /updates for inbound messages. Pass the last update ID you processed as offset to acknowledge everything before it. timeout_secs is capped at 25:

receive
# Long-poll for inbound messages (timeout capped at 25s)
curl -sk "https://YOUR_SERVER_IP:5881/v1/bot/<token>/updates?offset=0&timeout_secs=25"

# Who am I, and who is subscribed?
curl -sk "https://YOUR_SERVER_IP:5881/v1/bot/<token>/getme"
curl -sk "https://YOUR_SERVER_IP:5881/v1/bot/<token>/conns"

Alternatively, register a webhook and the relay will POST each update to your HTTPS endpoint as JSON. Every delivery carries an x-etoki-signature header containing a keyed BLAKE3 hash of the exact request body, computed with a per-bot secret. Recompute it and compare before trusting the payload — otherwise anyone who learns your endpoint URL can forge updates.

Status codes

CodeMeaning
200Accepted. Per-connection results are in the body.
400Bad JSON, missing text, no recipients, >500 recipients, or an invalid priority value.
401Unknown or malformed token.
404Unknown endpoint or bot.
405Wrong HTTP method for that endpoint.
429Rate limited — see below for which of the two limits fired.

Running the relay

The bot relay is a separate binary. It registers its manager username in the directory and serves the API on two listeners:

etoki-botrelay
etoki-botrelay \
  --state /var/lib/etoki-botrelay \
  --router etoki://<fp>@YOUR_SERVER_IP:5223 \
  --directory etoki://<fp>@YOUR_SERVER_IP:5663 \
  --http 127.0.0.1:5880 \
  --https 0.0.0.0:5881 --tls-sni relay.example.com
  • --http 127.0.0.1:5880 — plain HTTP, loopback only. Never expose this.
  • --https 0.0.0.0:5881 — TLS with a self-signed certificate. That is why the examples above use curl -sk.
Because the certificate is self-signed, -k skips verification entirely — fine for testing, not for production. Either pin the certificate in your client or tunnel the API over SSH or a real TLS proxy. The TLS-camouflage gateway cannot front this endpoint: its routing is protocol-specific and does not handle plain HTTP.

Trust model

The relay hosts the bot side of every conversation, so it can read bot conversations — exactly like any bot platform. It never sees ordinary user-to-user traffic, which stays end-to-end encrypted between devices. Self-hosting the relay removes even that exposure.