Platform reference
Everything behind the developer portal: apps and their tokens, scopes, OAuth2 installs, webhook subscriptions and how deliveries are signed and retried, and slash commands over HTTP. For a guided build, start with the Rust bot tutorial.
An app is a bot account plus everything around it: a token with scopes, OAuth2 client
credentials, redirect URIs and webhook subscriptions. The app's id is the bot's user id and its OAuth2
client_id.
Two ways to run a bot
| Gateway bot | HTTP bot | |
|---|---|---|
| Receives | Encrypted messages over the realtime socket | Signed webhook events |
| Sends | Encrypted messages, through the bot SDK | Plain-text replies through the REST API |
| End-to-end encrypted | Yes | No. Interactions are labeled NOT E2EE in the app |
| Needs | A long-running process and its state file | Any HTTPS endpoint |
| Scopes | gateway, e2ee, messages.* … | interactions, commands |
One app can do both. A slash command goes over HTTP only when the bot has a verified, enabled
subscription to interaction.command and holds interactions in its token and in that nest's grant.
Otherwise members' apps send it as an ordinary encrypted message.
The developer portal
The portal at aleja.app/developers is where apps are made and managed; the Aleja app only lists your bots and links to it. Sign in with your Aleja account: password, then an authenticator code or a security key.
A portal session token starts with adev_, lasts 12 hours, and reaches only /v1/developer/....
A leaked portal token can manage your apps, but can't read messages, change your account or act
in nests.
| Method and path | Body | What it does |
|---|---|---|
POST /v1/developer/login | { username, auth_key, code? } | Signs in with the Argon2-derived password proof (never the password) and an authenticator code. Returns { token, user, expires_at }, or says which second factor it needs |
POST /v1/developer/login/key | Security key assertion | Finishes sign-in with a security key |
POST /v1/developer/logout | Ends the session | |
GET /v1/developer/me | { user, expires_at, bot_limit } | |
GET /v1/developer/scopes | The scope and event catalog. No session needed |
Apps
| Method and path | Body | What it does |
|---|---|---|
GET /v1/developer/bots | Your apps: { bots: [...], limit } | |
POST /v1/developer/bots | { username, display_name?, public?, scopes? } | Creates an app. Returns { bot, token }; the token is shown only here |
PATCH /v1/developer/bots/{id} | { display_name?, about?, public?, scopes? } | Updates it. Returns { bot } |
DELETE /v1/developer/bots/{id} | Deletes it everywhere, with key rotation in every nest | |
POST /v1/developer/bots/{id}/token | Resets the token. Returns { token } | |
POST/DELETE /v1/developer/bots/{id}/avatar | Multipart file | Sets or clears the avatar |
GET/PUT /v1/developer/bots/{id}/commands | { commands } | Reads or replaces its slash commands |
DELETE /v1/developer/bots/{id}/nests/{server_id} | Takes it out of a nest |
An app object:
{
"id": "6f1c…",
"username": "dicebot",
"display_name": "Dice Bot",
"about": "",
"avatar_url": null,
"public": true,
"created_at": 1761234567000,
"online": false,
"keys_published": false,
"command_count": 4,
"scopes": ["commands", "interactions"],
"client_secret_set_at": 1761234600000,
"redirect_uri_count": 1,
"webhook_count": 1,
"live_webhook_count": 1,
"nests": [{ "id": "…", "name": "Tabletop", "scopes": ["interactions"] }],
"bot": true
}
nests[].scopes is what that nest granted; null means the bot is a member without an installation
record, and nest-scoped calls are refused there.
Tokens
A bot token is abot_ followed by 64 hex characters from the operating system's secure random
generator. The server never stores it: it keeps a keyed digest,
k1: + HMAC-SHA256(pepper, "aleja-credential-v1" ‖ "bot-token" ‖ token)
where the pepper is a server secret held outside the database. A copy of the database alone can't
be used to test guesses. Client secrets (acs_…) and authorization codes (acode_…) are stored the
same way under their own labels. Tokens made before keyed digests are upgraded the first time they're
used.
Scopes
A token reaches only the endpoints its scopes cover. Endpoints outside the list answer
403 Bots can't use this endpoint.; a listed endpoint whose scope the token lacks answers 403
naming the scope. Scope changes reach a running bot within 30 seconds.
| Scope | Lets the bot | Endpoints |
|---|---|---|
gateway | Connect to the realtime socket and load its nests and conversations | GET /v1/ws, GET /v1/bootstrap |
e2ee | Publish its keys and exchange sender keys | /v1/crypto/..., GET /v1/channels/{id}/crypto, POST /v1/channels/{id}/crypto/request |
messages.read | Read history, pins and channel state | GET /v1/channels/{id}/messages, …/bootstrap, …/pins, POST …/read |
messages.write | Send, edit and delete its own messages | POST /v1/channels/{id}/messages, PATCH/DELETE /v1/messages/{id} |
reactions | React | POST /v1/messages/{id}/reactions |
pins | Pin and unpin | POST /v1/messages/{id}/pin |
media | Upload and download files | /v1/media/... |
members.read | List members of its nests | GET /v1/servers/{id}/members |
commands | Register its slash commands | GET/PUT /v1/bots/@me/commands |
interactions | Take slash commands over HTTP and answer them, not end-to-end encrypted | POST /v1/interactions/{id}/messages, PATCH /v1/interactions/{id}/messages/{message_id} |
GET /v1/me always works. The portal preselects every scope except interactions for a new app;
through the API, leaving scopes out grants all of them.
Per-nest grants. A nest grants a subset of the token's scopes when it installs the app. For a request that acts in a nest (a channel, a message or the nest itself), the bot needs the scope in both the token and that nest's grant. Direct messages and global endpoints check the token only.
OAuth2 installs
Installing an app in a nest uses the authorization code flow with PKCE (RFC 6749, RFC 7636). There's no access token: the bot already has its token, and the exchange confirms which nest installed it with which scopes.
Setup
| Method and path | Body | What it does |
|---|---|---|
GET /v1/developer/bots/{id}/oauth2 | { client_id, redirect_uris, redirect_uri_limit, client_secret_set_at, scopes, authorize_url } | |
PUT /v1/developer/bots/{id}/oauth2/redirect-uris | { redirect_uris: [...] } | Replaces the list (up to 10) |
POST /v1/developer/bots/{id}/oauth2/secret | Issues a client secret, shown once. The old one stops working |
Redirect URIs must be absolute https URLs, or http on localhost, 127.0.0.1 or [::1], without
a fragment or credentials. They're normalized and matched exactly.
The flow
-
Your app makes a
code_verifier(43–128 characters ofA-Z a-z 0-9 - . _ ~) and astate, keeps both, and sends the person to:https://aleja.app/developers/authorize/ ?response_type=code &client_id=<app id> &redirect_uri=<registered URI> &scope=interactions%20commands &state=<state> &code_challenge=<BASE64URL(SHA-256(code_verifier))> &code_challenge_method=S256 -
The consent screen signs them in, shows the app, its owner and the scopes, and lists the nests where they have Manage Nest. Requested scopes must be within the app's own scopes. Errors in the request (unknown client, unregistered redirect URI, no client secret yet, bad challenge) are shown there and never redirected.
-
On approval the app is installed (or its grant replaced, if it was already there) and the browser goes to
redirect_uri?code=acode_…&server_id=<nest>&state=<state>. On refusal it goes toredirect_uri?error=access_denied&error_description=…&state=<state>. -
Your server checks
state, then exchanges the code within 5 minutes:POST /v1/oauth2/token Authorization: Basic base64(client_id:client_secret) Content-Type: application/x-www-form-urlencoded grant_type=authorization_code&code=acode_…&redirect_uri=…&code_verifier=…{ "scope": "interactions", "requested_scope": "interactions commands", "installation": { "bot_id": "6f1c…", "nest": { "id": "…", "name": "Tabletop" }, "scopes": ["interactions"], "installed_by": { "id": "…", "username": "alex", "display_name": "Alex" }, "installed_at": 1761234567000 } }Credentials can also go in the form as
client_idandclient_secret. Codes work once. Errors follow RFC 6749:invalid_client(401),invalid_grant,unsupported_grant_type(400), each witherror_description. Responses carryCache-Control: no-store.
Adding a bot by username in Nest settings → Integrations still works and grants the token's full scope set.
Webhook subscriptions
An app can have up to 5 subscriptions. Each has a URL, a list of events, and its own signing secret.
| Method and path | Body | What it does |
|---|---|---|
GET /v1/developer/bots/{id}/webhooks | { webhooks, events, limit }, with 24-hour stats per subscription | |
POST /v1/developer/bots/{id}/webhooks | { url, events } | Creates one. Returns { webhook, secret }; the secret is shown only here |
PATCH /v1/developer/bots/{id}/webhooks/{hook} | { url?, events?, enabled? } | Updates it. A new URL needs verifying again |
DELETE /v1/developer/bots/{id}/webhooks/{hook} | Deletes it and its log | |
POST /v1/developer/bots/{id}/webhooks/{hook}/verify | Sends a signed ping. Returns { webhook, result: { ok, status, error, duration_ms } } | |
POST /v1/developer/bots/{id}/webhooks/{hook}/secret | Rotates the secret. Returns { webhook, secret } | |
GET /v1/developer/bots/{id}/webhooks/{hook}/deliveries | The latest 50 deliveries, with payloads | |
POST /v1/developer/bots/{id}/webhooks/{hook}/deliveries/{delivery}/retry | Sends a finished delivery again |
Verification
A new subscription receives nothing until it's verified. Verify sends:
{ "id": "…", "type": "ping", "created_at": 1761234567000, "bot_id": "…", "data": { "challenge": "4f0c…" } }
The endpoint must answer 2xx with {"challenge": "4f0c…"} within 10 seconds. Echoing the challenge
proves you control the endpoint and that it checked the signature, since only a holder of the secret
should answer.
Endpoint rules
httpsonly, no credentials in the URL, no fragment, at most 512 characters.- The host must resolve to public addresses only. Loopback, private (RFC 1918), link-local (cloud
metadata), carrier-grade NAT, multicast, documentation, benchmarking and reserved ranges, IPv6
unique-local, and IPv6 forms that embed an IPv4 address are refused, as are names like
localhost,*.localand*.internal. The check runs when you save the URL and on every connection, so DNS can't be pointed somewhere private later. - Redirects aren't followed. Proxies aren't used.
- A self-hosted server can allow
httpand local addresses withALEJA_WEBHOOKS_ALLOW_LOCAL=1, for development only.
Events
Every delivery is a JSON envelope:
{
"id": "0b1e…",
"type": "interaction.command",
"created_at": 1761234567000,
"bot_id": "6f1c…",
"data": { }
}
id is the delivery id. It's the same on every retry and redelivery, so dedupe on it.
| Event | When | data |
|---|---|---|
ping | You press Verify | { challenge } |
interaction.command | Someone runs one of the app's slash commands over HTTP | See interactions |
installation.created | A nest installs the app | { nest: { id, name }, installed_by: { id, username, display_name }, scopes, installed_at } |
installation.updated | A nest re-authorizes it with different scopes | Same as installation.created |
installation.deleted | The app leaves a nest | { nest: { id, name }, reason } |
reason is removed_by_nest, removed_by_owner, banned, nest_deleted or bot_deleted.
Signing
Each request carries:
| Header | Value |
|---|---|
Aleja-Signature | t=<unix seconds>,v1=<hex>; a second v1= follows for 24 hours after a secret rotation |
Aleja-Event | The event type |
Aleja-Delivery | The delivery id, as in the body |
Aleja-Attempt | 1 for the first try, then 2, 3 … |
Aleja-Timestamp | Same as t |
User-Agent | Aleja-Webhooks/<version> |
Content-Type | application/json |
Each v1 is:
hex( HMAC-SHA256( key = signing secret (the whole "whsec_…" string),
message = t + "." + raw request body ) )
To check a request:
- Read the raw body bytes before parsing JSON. Re-serialized JSON won't match.
- Split the header on
,; taketand everyv1. - Refuse it if
tis more than 300 seconds from your clock. That stops replays. - Compute the HMAC and compare with each
v1in constant time. Accept if any matches.
Rotating a secret signs with the new one at once and keeps signing with the old one for 24 hours, so you can deploy the new secret without missing events. Secrets are stored encrypted with the server's key, since the server must sign with them; payloads are encrypted at rest too.
Delivery and retries
Events are queued in the database and sent by a worker on each server, so they survive restarts.
| Timeout | 5 s to connect, 10 s in all |
| Success | Any 2xx |
| Retried | Network errors, timeouts, 408, 429 and 5xx |
| Final | Every other status, including 3xx and 4xx |
| Attempts | 8 in all |
| Backoff | 10 s, doubling, capped at 1 hour, with equal jitter: each wait is a random point in the upper half of its step |
Retry-After | Honored, in seconds, up to 1 hour, when longer than the backoff |
| Expiry | interaction.command stops retrying when the interaction expires (15 minutes) |
| Ordering | Not guaranteed. Use created_at when order matters |
| Log | Kept 7 days |
The waits before attempts 2 through 8 are at most 10, 20, 40, 80, 160, 320 and 640 seconds, about 21 minutes in all.
Circuit breaker. After 10 deliveries in a row end in failure (each after its retries run out),
the subscription is switched off
and disabled_reason says why. Fix the endpoint and switch it back on; redeliver what you missed
from the log.
Answer fast. Acknowledge with 2xx first and do slow work afterwards. A bot that takes longer than 10 seconds gets the same event again.
Slash commands over HTTP
When a member runs an HTTP bot's command, their app posts the invocation to Aleja in plain text:
- Aleja checks it like a message: membership, Send Messages, Use Application Commands, slowmode, blocks and rate limits. It checks the options against the bot's definitions, as the SDK does.
- It posts the invocation as a visible message, such as
/roll dice:2d20+3, marked NOT E2EE. - It queues
interaction.commandfor the bot:
{
"interaction": { "id": "9c2d…", "expires_at": 1761235467000, "reply_path": "/v1/interactions/9c2d…/messages" },
"command": { "name": "roll", "options": { "dice": "2d20+3" } },
"channel": { "id": "…", "name": "general", "kind": "nest" },
"nest": { "id": "…", "name": "Tabletop" },
"user": { "id": "…", "username": "alex", "display_name": "Alex" },
"message_id": "…"
}
channel.kind is nest or dm; in a direct message nest is null and channel.name is null.
user options arrive as user ids.
The bot answers with its token, for 15 minutes:
| Method and path | Body | What it does |
|---|---|---|
POST /v1/interactions/{id}/messages | { content } | Posts a reply. Returns { id, channel_id, created_at }. Up to 5 per interaction |
PATCH /v1/interactions/{id}/messages/{message_id} | { content } | Edits one of those replies |
Replies follow the normal message length limit and are marked NOT E2EE. After expiry both answer
410 Gone. Members can't edit interaction messages.
The invocation and replies are stored encrypted with the server's key, like incoming webhook posts, not end-to-end encrypted. Ordinary messages in the same channel stay end-to-end encrypted.
Data model
The tables behind the platform, in PostgreSQL. Times are Unix milliseconds.
-- identity: the app, its credentials and its subscriptions
CREATE TABLE identity.bots (
user_id TEXT PRIMARY KEY, -- the bot's account; also the OAuth2 client_id
owner_id TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE, -- "k1:" keyed digest of the abot_ token
public BIGINT NOT NULL DEFAULT 0,
commands TEXT NOT NULL DEFAULT '[]', -- slash command definitions, JSON
scopes TEXT NOT NULL, -- space-separated
client_secret_hash TEXT, -- keyed digest of the acs_ secret
client_secret_at BIGINT,
created_at BIGINT NOT NULL,
deleted_at BIGINT
);
CREATE TABLE identity.bot_redirect_uris (
bot_id TEXT NOT NULL,
uri TEXT NOT NULL,
created_at BIGINT NOT NULL,
PRIMARY KEY (bot_id, uri)
);
CREATE TABLE identity.bot_oauth_codes (
code_hash TEXT PRIMARY KEY, -- keyed digest of the acode_ code
bot_id TEXT NOT NULL,
server_id TEXT NOT NULL,
user_id TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
scopes TEXT NOT NULL,
code_challenge TEXT NOT NULL, -- S256
created_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL, -- created_at + 5 minutes
used_at BIGINT
);
CREATE TABLE identity.bot_webhooks (
id TEXT PRIMARY KEY,
bot_id TEXT NOT NULL,
url TEXT NOT NULL,
events TEXT NOT NULL, -- space-separated
secret TEXT NOT NULL, -- encrypted with the server key
previous_secret TEXT, -- still signs until previous_until
previous_until BIGINT,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
verified_at BIGINT,
failure_streak INTEGER NOT NULL DEFAULT 0,
disabled_reason TEXT,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE TABLE identity.bot_deliveries (
id TEXT PRIMARY KEY, -- also the envelope id and Aleja-Delivery
webhook_id TEXT NOT NULL,
bot_id TEXT NOT NULL,
event TEXT NOT NULL,
payload TEXT NOT NULL, -- encrypted with the server key
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'delivered', 'failed')),
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at BIGINT NOT NULL,
locked_until BIGINT NOT NULL DEFAULT 0, -- a worker's lease on the row
expires_at BIGINT, -- stop retrying after this
last_status INTEGER,
last_error TEXT,
last_duration_ms INTEGER,
created_at BIGINT NOT NULL,
finished_at BIGINT
);
CREATE INDEX bot_deliveries_due_idx ON identity.bot_deliveries (next_attempt_at) WHERE status = 'pending';
-- spaces: which nests installed which app, with what
CREATE TABLE spaces.bot_installations (
server_id TEXT NOT NULL,
bot_id TEXT NOT NULL,
scopes TEXT NOT NULL, -- the nest's grant, within the token's scopes
installed_by TEXT NOT NULL,
installed_at BIGINT NOT NULL,
PRIMARY KEY (server_id, bot_id)
);
-- history: slash commands sent over HTTP
CREATE TABLE history.interactions (
id TEXT PRIMARY KEY,
bot_id TEXT NOT NULL,
user_id TEXT NOT NULL,
channel_id TEXT NOT NULL,
server_id TEXT, -- NULL in direct messages
command TEXT NOT NULL,
message_id TEXT NOT NULL, -- the visible invocation
replies INTEGER NOT NULL DEFAULT 0, -- at most 5
created_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL -- created_at + 15 minutes
);
Workers claim due deliveries with FOR UPDATE SKIP LOCKED and a 60-second lease in locked_until,
so several servers can share one queue without sending an event twice at once. A wake-up signal
starts a claim as soon as an event is queued; a 2-second poll covers the rest. Each server sends at
most 32 deliveries at a time.
Limits
| Apps per account | 10 |
| Webhook subscriptions per app | 5 |
| Redirect URIs per app | 10 |
OAuth2 state | 512 characters |
| Authorization code lifetime | 5 minutes, single use |
| Interaction lifetime | 15 minutes, 5 replies |
| Portal session | 12 hours |
| Delivery log | 7 days, latest 50 shown |
