Docs / Ref. 01 aleja.app ↗

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 botHTTP bot
ReceivesEncrypted messages over the realtime socketSigned webhook events
SendsEncrypted messages, through the bot SDKPlain-text replies through the REST API
End-to-end encryptedYesNo. Interactions are labeled NOT E2EE in the app
NeedsA long-running process and its state fileAny HTTPS endpoint
Scopesgateway, 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 pathBodyWhat 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/keySecurity key assertionFinishes sign-in with a security key
POST /v1/developer/logoutEnds the session
GET /v1/developer/me{ user, expires_at, bot_limit }
GET /v1/developer/scopesThe scope and event catalog. No session needed

Apps

Method and pathBodyWhat it does
GET /v1/developer/botsYour 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}/tokenResets the token. Returns { token }
POST/DELETE /v1/developer/bots/{id}/avatarMultipart fileSets 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.

ScopeLets the botEndpoints
gatewayConnect to the realtime socket and load its nests and conversationsGET /v1/ws, GET /v1/bootstrap
e2eePublish its keys and exchange sender keys/v1/crypto/..., GET /v1/channels/{id}/crypto, POST /v1/channels/{id}/crypto/request
messages.readRead history, pins and channel stateGET /v1/channels/{id}/messages, …/bootstrap, …/pins, POST …/read
messages.writeSend, edit and delete its own messagesPOST /v1/channels/{id}/messages, PATCH/DELETE /v1/messages/{id}
reactionsReactPOST /v1/messages/{id}/reactions
pinsPin and unpinPOST /v1/messages/{id}/pin
mediaUpload and download files/v1/media/...
members.readList members of its nestsGET /v1/servers/{id}/members
commandsRegister its slash commandsGET/PUT /v1/bots/@me/commands
interactionsTake slash commands over HTTP and answer them, not end-to-end encryptedPOST /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 pathBodyWhat 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/secretIssues 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

  1. Your app makes a code_verifier (43–128 characters of A-Z a-z 0-9 - . _ ~) and a state, 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
    
  2. 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.

  3. 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 to redirect_uri?error=access_denied&error_description=…&state=<state>.

  4. 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_id and client_secret. Codes work once. Errors follow RFC 6749: invalid_client (401), invalid_grant, unsupported_grant_type (400), each with error_description. Responses carry Cache-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 pathBodyWhat 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}/verifySends a signed ping. Returns { webhook, result: { ok, status, error, duration_ms } }
POST /v1/developer/bots/{id}/webhooks/{hook}/secretRotates the secret. Returns { webhook, secret }
GET /v1/developer/bots/{id}/webhooks/{hook}/deliveriesThe latest 50 deliveries, with payloads
POST /v1/developer/bots/{id}/webhooks/{hook}/deliveries/{delivery}/retrySends 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

  • https only, 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, *.local and *.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 http and local addresses with ALEJA_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.

EventWhendata
pingYou press Verify{ challenge }
interaction.commandSomeone runs one of the app's slash commands over HTTPSee interactions
installation.createdA nest installs the app{ nest: { id, name }, installed_by: { id, username, display_name }, scopes, installed_at }
installation.updatedA nest re-authorizes it with different scopesSame as installation.created
installation.deletedThe 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:

HeaderValue
Aleja-Signaturet=<unix seconds>,v1=<hex>; a second v1= follows for 24 hours after a secret rotation
Aleja-EventThe event type
Aleja-DeliveryThe delivery id, as in the body
Aleja-Attempt1 for the first try, then 2, 3 …
Aleja-TimestampSame as t
User-AgentAleja-Webhooks/<version>
Content-Typeapplication/json

Each v1 is:

hex( HMAC-SHA256( key = signing secret (the whole "whsec_…" string),
                  message = t + "." + raw request body ) )

To check a request:

  1. Read the raw body bytes before parsing JSON. Re-serialized JSON won't match.
  2. Split the header on ,; take t and every v1.
  3. Refuse it if t is more than 300 seconds from your clock. That stops replays.
  4. Compute the HMAC and compare with each v1 in 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.

Timeout5 s to connect, 10 s in all
SuccessAny 2xx
RetriedNetwork errors, timeouts, 408, 429 and 5xx
FinalEvery other status, including 3xx and 4xx
Attempts8 in all
Backoff10 s, doubling, capped at 1 hour, with equal jitter: each wait is a random point in the upper half of its step
Retry-AfterHonored, in seconds, up to 1 hour, when longer than the backoff
Expiryinteraction.command stops retrying when the interaction expires (15 minutes)
OrderingNot guaranteed. Use created_at when order matters
LogKept 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:

  1. 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.
  2. It posts the invocation as a visible message, such as /roll dice:2d20+3, marked NOT E2EE.
  3. It queues interaction.command for 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 pathBodyWhat 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 account10
Webhook subscriptions per app5
Redirect URIs per app10
OAuth2 state512 characters
Authorization code lifetime5 minutes, single use
Interaction lifetime15 minutes, 5 replies
Portal session12 hours
Delivery log7 days, latest 50 shown