Developers. Bot API & Webhooks
Automate your community with bots, or post messages via an incoming webhook. The SpeakSpeak API is Discord-compatible: popular bot libraries port over with a few tweaks.
Getting started
- Create an application. In your SpeakSpeak account's Developer settings, create an application. Each application comes with a bot user.
- Generate a bot token. The application issues a token shaped
ssbot_<app>.<secret>. It is shown only once, store it safely; it is as powerful as a password. - Install the bot. Install the application into a server where you have Manage Server, and choose its permissions. Only then can the bot act there.
From there, your bot talks to the API directly:
# Who am I?, confirms the token is valid
curl https://api.speakspeak.net/api/v1/users/@me \
-H "Authorization: Bot ssbot_AZ-wUWG0….ovB1whFr…"
Authentication
Every REST request carries the Authorization header with the Bot prefix:
Authorization: Bot ssbot_<app>.<secret>
- The token is a ceiling, not a grant. What a bot may actually do is always token permissions ∩ the bot's role on that server, re-checked on the target server on every call. A token can never exceed what the bot's role grants.
- Installed per server. A bot acts only on servers where the application is installed.
- Revocable. Revoke a leaked token any time in Developer settings; calls using it fail immediately.
REST API
Discord-compatible endpoints under https://api.speakspeak.net/api/v1. IDs are UUIDs (rather than Discord snowflakes); otherwise paths and JSON shapes follow the familiar scheme.
| Area | Examples |
|---|---|
| Identity | GET /users/@me, GET /users/{id} |
| Servers | GET /guilds/{id}, GET /guilds/{id}/members, GET /guilds/{id}/audit-logs |
| Channels | GET|PATCH|DELETE /channels/{id}, POST /guilds/{id}/channels |
| Messages | GET|POST /channels/{id}/messages, GET|PATCH|DELETE /channels/{id}/messages/{mid} |
| Reactions | PUT|DELETE /channels/{id}/messages/{mid}/reactions/{emoji}/@me |
| Pins | GET /channels/{id}/pins, PUT|DELETE /channels/{id}/pins/{mid} |
| Roles | GET|POST /guilds/{id}/roles, PATCH|DELETE /guilds/{id}/roles/{rid}, PUT|DELETE /guilds/{id}/members/{uid}/roles/{rid} |
| Members | PATCH /guilds/{id}/members/{uid} (nick/roles), DELETE … (kick) |
| Bans | GET /guilds/{id}/bans, GET|PUT|DELETE /guilds/{id}/bans/{uid} |
| Invites | POST /channels/{id}/invites, DELETE /invites/{code} |
| Typing | POST /channels/{id}/typing |
Send a message, and the response:
curl -X POST https://api.speakspeak.net/api/v1/channels/{channel_id}/messages \
-H "Authorization: Bot ssbot_…" \
-H "Content-Type: application/json" \
-d '{"content": "Hello from my bot 👋"}'
{
"id": "019fb0a1-2c3d-7e4f-…",
"channel_id": "019fb0…",
"content": "Hello from my bot 👋",
"author": { "id": "019fb0…", "username": "mybot#bot", "bot": true },
"timestamp": "2026-07-30T13:12:42.000Z"
}
Discord-style limits apply: content max 2000 chars, role name max 100, channel topic max 1024. Over the limit, or a null byte in text, returns a clean 400.
Incoming webhooks
A webhook posts to exactly one channel, with no bot and no login, the credential lives in the URL. Perfect for CI, monitoring or deploy notifications.
- Create it in the channel settings (or via the API). You get a
webhook_idand atoken, the token only once. - Fire it with a simple
POSTto the webhook URL.
curl -X POST "https://api.speakspeak.net/api/v1/webhooks/{webhook_id}/{token}?wait=true" \
-H "Content-Type: application/json" \
-d '{"content": "✅ Build 2016 deployed", "username": "CI"}'
?wait=true makes the server return the created message (handy for confirmation). If the channel is saturated, the webhook answers 429 with a Retry-After header, wait briefly and resend.
Gateway (real-time events)
For live events (new messages, reactions, joins) your bot holds a WebSocket to the gateway. The flow mirrors Discord: HELLO (op 10) → IDENTIFY (op 2) → READY, then regular heartbeats.
// Node.js, minimal handshake (no library)
const ws = new WebSocket("wss://api.speakspeak.net/api/v1/gateway");
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.op === 10) { // HELLO
setInterval(() => ws.send(JSON.stringify({ op: 1, d: null })),
m.d.heartbeat_interval); // heartbeat
ws.send(JSON.stringify({ op: 2, d: { // IDENTIFY
token: "ssbot_…", intents: 33281, // GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT
properties: { os: "linux", browser: "mybot", device: "mybot" }
}}));
} else if (m.t === "MESSAGE_CREATE") {
console.log("new message:", m.d.content);
}
};
Which events you receive is controlled by intents (a bitfield in IDENTIFY), exactly as on Discord. The gateway knows these bits:
| Intent | Bit | Value | Unlocks |
|---|---|---|---|
GUILDS | 1 << 0 | 1 | channel, thread, role and guild-metadata events |
GUILD_MEMBERS | 1 << 1 | 2 | GUILD_MEMBER_ADD/REMOVE/UPDATE |
GUILD_VOICE_STATES | 1 << 7 | 128 | VOICE_STATE_UPDATE |
GUILD_PRESENCES | 1 << 8 | 256 | accepted, but delivers no events (see "Write-only" above) |
GUILD_MESSAGES | 1 << 9 | 512 | MESSAGE_CREATE/UPDATE/DELETE |
GUILD_MESSAGE_REACTIONS | 1 << 10 | 1024 | MESSAGE_REACTION_ADD/REMOVE |
GUILD_MESSAGE_TYPING | 1 << 11 | 2048 | TYPING_START |
MESSAGE_CONTENT | 1 << 15 | 32768 | the content of message events, not the events themselves (see below) |
Unknown bits are ignored. intents: 0 is valid but delivers zero events, a bot that should react to messages needs at least GUILD_MESSAGES.
MESSAGE_CONTENT: event arrives, content is empty?
MESSAGE_CONTENT (1 << 15) does not decide whether message events arrive, GUILD_MESSAGES (1 << 9) does. MESSAGE_CONTENT only decides whether the content fields are populated: without the bit, MESSAGE_CREATE still arrives, but content is empty and embeds and attachments are empty lists, exactly like Discord's privileged intent. The symptom: your handler fires and m.d.content is "". The fix: set MESSAGE_CONTENT in addition to GUILD_MESSAGES, together with GUILDS that is intents: 33281 as in the example above. Unlike Discord there is no approval process, setting the bit is enough; what the bot can see at all is still bounded by its channel permissions.
Your bot's status (op 3)
PRESENCE_UPDATE (op 3) sets how your bot appears in the member list, across every server it is in at once. You can also send the same object inline in IDENTIFY as presence; without one, your bot is online from READY.
ws.send(JSON.stringify({ op: 3, d: {
status: "dnd", // online | idle | dnd | invisible | offline
activities: [{ type: 0, name: "Celeste" }] // → "Playing Celeste"
}}));
- Only
activities[0]is used: type0→ "Playing X",1→ "Streaming X",2→ "Listening to X",3→ "Watching X",5→ "Competing in X". Type4(custom) uses yourstateverbatim with no prefix. Capped at 128 characters, prefix included. - The prefixes are English on every locale, the line is half your own text, and translating only our half reads worse than keeping it consistent.
- An unrecognised
statusbecomesonline, never an error, never a dropped connection.sinceandafkare accepted and ignored. - There is no response. Success is silent, exactly as on Discord.
- At most one change per 5 seconds per connection. Updates inside that window are not rejected or queued, they overwrite each other, and the newest value is applied when the window closes.
- Write-only: your bot never receives
PRESENCE_UPDATE, whatever intents it requested.GUILD_PRESENCES(1 << 8) is accepted but delivers nothing, it exists so Discord-shaped libraries can request it without erroring. - Your status is re-asserted automatically every 60 seconds and restored after any internal reconnect, set it once and it persists.
Connection lost: RESUME (op 6)
READY carries a session_id and a resume_gateway_url. Remember both, plus the highest sequence number s you have received on dispatch frames. When the connection drops, open a new WebSocket to the resume_gateway_url and send, as the first frame instead of an IDENTIFY:
ws.send(JSON.stringify({ op: 6, d: { // RESUME
token: "ssbot_…",
session_id: "…from READY…",
seq: lastSeq // highest s received
}}));
If the resume succeeds, the gateway replays every missed event in order and finishes with a RESUMED dispatch. There is no second READY. The window is 90 seconds after the drop, and the buffer lives in the gateway instance's memory: after a gateway restart (e.g. a deploy) every session is gone.
If the session is no longer resumable (window expired, unknown session_id, a seq too old, gateway restart, malformed RESUME frame), the gateway answers with op 9 Invalid Session (d: false). Then: new connection, fresh IDENTIFY, carry on as at first start. Discord libraries do exactly that automatically. The token is fully re-verified on every resume, a revoked token gets close code 4004 and never a replay.
Close codes
| Code | Meaning | What to do |
|---|---|---|
4004 | authentication failed, token invalid or revoked (including mid-session) | Do not reconnect automatically, check the token first |
4008 | rate limited: too many IDENTIFYs or too many concurrent sessions | Wait, then reconnect |
4009 | session timeout: heartbeat missed (dropped at 1.5× the advertised interval) | Reconnect, a RESUME inside the 90-second window works |
Every other drop (network loss, gateway shutdown) arrives as a plain socket close with no specific code, try a RESUME first there too, and re-identify on Invalid Session.
Rate limits
Limits are bucketed per bot and per server. Every response carries the counters; a 429 tells you how long to wait.
| Header | Meaning |
|---|---|
X-RateLimit-Limit | requests per window |
X-RateLimit-Remaining | left in the current window |
X-RateLimit-Reset-After | seconds until reset |
Retry-After | on 429: wait this long |
A 429 body is Discord-shaped: { "message": "…", "retry_after": 7.457, "global": false }. Respect Retry-After, popular libraries do this automatically.
Errors
Errors come back as JSON with a meaningful code, compatible with Discord's { message, code } scheme.
| HTTP | Meaning |
|---|---|
401 | token missing, invalid or revoked |
403 | the bot lacks permission (role/rights) |
404 | resource not found, or outside the bot's reach (deliberately indistinguishable, so foreign IDs can't be probed) |
400 | invalid input (e.g. too long, null byte, wrong field) |
429 | rate limited, see Retry-After |
Porting a library
Because the API follows the common bot standard, existing libraries often run with three changes:
- Point the base URL at
https://api.speakspeak.net/api/v1and the gateway atwss://api.speakspeak.net/api/v1/gateway. - ID format: SpeakSpeak uses UUIDs instead of 64-bit snowflakes, code that treats IDs as numbers must carry them as strings.
- Check permission bits against SpeakSpeak's permission model.
The full API reference — every endpoint, object, error code, rate limit and gateway intent — is at speakspeak.net/en/api. Porting questions (including individual permission bits) are answered directly: contact@speakspeak.net.
Public beta. The Bot API is available and growing. Questions, feedback or a library request? Write to contact@speakspeak.net.