HTTP API¶
Use the HTTP API to read and change resources. Every route lives under one base URL:
Paths on these pages are written relative to that base, so GET /channels/{channel_id} means GET https://tebhos.com/api/v1/channels/{channel_id}. A client that needs to discover the base URL, or any other endpoint of the deployment, reads /.well-known/tebhos first. The endpoints.api_public value there is the API root (it ends in /api), and the client appends /v1 itself.
v1 is the only API version. There is no version negotiation header and no older version to fall back to.
Resource limits such as attachment counts, message length, and guild expression counts are published in the limit configuration of instance discovery. Read them from there instead of hard-coding them.
Conventions¶
Snowflakes¶
Every ID is a snowflake: a 64-bit integer that the API always serialises as a decimal string, for example "1501314428688998182". Send IDs back as strings too. A JavaScript client MUST NOT parse an ID into a number, because a snowflake does not fit in a double-precision float.
Snowflakes encode their creation time and sort chronologically, which is what the cursor pagination below relies on.
Timestamps¶
Timestamps are ISO 8601 / RFC 3339 strings in UTC, for example "2026-09-23T00:15:57.000Z". A few fields that come from the realtime layer or from a cache carry Unix time instead, and those are called out on their own page (for example exp and iat in OAuth2 introspection).
Field notation¶
Field tables on every resource page use the same notation:
| Notation | Meaning |
|---|---|
field? |
The field is optional: it can be omitted from a request, or absent from a response |
?type |
The value is nullable: the field is present but can be null |
field? with ?type |
The field can be absent and can also be null |
A client MUST treat an unknown response field as absent rather than failing, because fields are added over time.
Pagination¶
List routes that can grow without bound are cursor-paginated with snowflakes. Where a route supports it, pass before, after, or around with a snowflake ID and limit with a count; the route page states the default and maximum limit. Small, bounded collections (for example connections or the applications a user owns) are returned whole.
Request format¶
Send JSON bodies with Content-Type: application/json. Responses are UTF-8 JSON unless an operation states otherwise. Exceptions are called out on their resource page:
- OAuth2 token, introspection, and revocation take
application/x-www-form-urlencodedbodies. - Message creation and webhook execution also accept
multipart/form-data, described on Messages and Webhooks. - File uploads use presigned URLs; see Attachment uploads.
A body that is not valid JSON, or whose shape does not match the operation (a missing required field, a wrong type), is rejected with 400 before the operation runs.
Unknown paths return 404 with an empty body. A known path called with a method it does not support also returns 404 with an empty body, not 405. Trailing slashes are significant: /users/@me/ is a different path from /users/@me.
Only the public media routes (/files/{key}, /avatars/..., /banners/..., /server-icons/..., and the other image routes described in Media proxy routes) answer HEAD. On every other route, HEAD is treated like any other unsupported method.
Authentication¶
Most routes require an Authorization header. TEBHOS accepts two schemes on the REST API:
| Scheme | Example | Used by |
|---|---|---|
Bearer |
Authorization: Bearer <session token> |
A signed-in user. The session token is returned by login and the other sign-in operations |
Bot |
Authorization: Bot <application_id>.<secret> |
A bot account. The token is returned when the application is created |
A user session token may also be sent bare, without the Bearer prefix. Authentication describes both credentials in full.
Each route states which credentials it accepts. Many user-facing routes are user-only: they read a session token and reject a valid bot token with 403 ACCESS_DENIED (an invalid one with 401). Routes that bots use (messages, reactions, channels, guild reads, and so on) accept both. OAuth2 bearer access tokens are accepted only by the two OAuth2 routes that document them (user information and current authorisation) and by a few read routes that require a matching scope; OAuth2 access tokens lists them. Any other route answers a valid access token with 403 ACCESS_DENIED.
Sensitive account operations additionally require sudo mode: a fresh proof of the password, an authenticator code, or a passkey, supplied in the request body or granted to the session for five minutes.
Standard request headers¶
| Field | Type | Description |
|---|---|---|
| Authorization? | string | The credential, in one of the accepted schemes |
| Content-Type? | string | The request body media type, and for multipart bodies the boundary |
| Accept-Language? | string | Read by registration to seed the new account's locale. Error messages are not localised by this header |
| X-Captcha-Token? | string | The CAPTCHA solution, on the operations that are CAPTCHA-gated (see CAPTCHA handling) |
| X-Captcha-Type? | string | hcaptcha or turnstile, selecting the provider that issued the token. Omit it to use the instance's configured provider |
| X-Tebhos-Platform? | string | The native client platform, read by desktop handoff. The TEBHOS desktop app sends desktop |
| X-Tebhos-Features? | string | A comma-separated list of client capabilities. Currently only view_channel_members_permission is recognised, by role updates |
| User-Agent? | string | Recorded on a new authentication session and shown in the session list. Truncated to 255 characters |
No audit-log reason header
TEBHOS does not read an audit-log reason from a request header. Operations that record a reason take it from their documented request fields.
Standard response headers¶
| Field | Type | Description |
|---|---|---|
| Content-Type? | string | The media type of the body, absent from an empty response |
| Cache-Control? | string | Set by operations that need it, for example no-cache on instance discovery and public, max-age=31536000 on public media |
| Retry-After? | string | Whole seconds to wait, sent on every 429 and on a slowmode denial |
| X-RateLimit-Limit? | string | The bucket capacity, sent on a 429 from a route bucket |
| X-RateLimit-Remaining? | string | Always 0 on a 429 from a route bucket |
| X-RateLimit-Reset? | string | Unix time in seconds when the bucket is fully drained |
| X-RateLimit-Reset-After? | string | Seconds, with a fractional part, until the bucket resets |
| X-RateLimit-Bucket? | string | A stable 16-character hash of the bucket name |
| X-RateLimit-Scope? | string | user, shared, or global, sent on every 429 |
| X-RateLimit-Global? | string | The literal true, sent only on a global 429 |
| X-Total-Count? | string | The total count, on the operations that report one (for example the older /servers/{guild_id}/members list described under TEBHOS member object) |
The X-RateLimit-* headers are sent only on a rate-limit denial, not on successful responses. Rate limits defines the full contract.
Rate limits¶
Every authenticated /api/v1 request counts against a global limit of 50 requests per second per client. The client is identified by its Authorization header, or by its IP address when it sends none. Unauthenticated GET/HEAD requests, public media reads, and webhook execution are exempt from the global limit.
Most routes also have their own route bucket, documented on each operation. A denial from either returns 429 with code RATE_LIMITED:
{
"error": "429 Too Many Requests",
"code": "RATE_LIMITED",
"message": "...",
"global": false,
"retry_after": 1.25
}
retry_after is in seconds with a fractional part; Retry-After is the same value rounded up to whole seconds. Rate limits lists the buckets and the header contract.
Cross-origin requests¶
Browser requests are allowed from the TEBHOS web origins (https://tebhos.com and its *.tebhos.com subdomains). For an allowed Origin, the response carries Access-Control-Allow-Origin set to that origin and Access-Control-Allow-Credentials: true. Requests from any other origin receive no Access-Control-Allow-Origin, so a browser blocks them.
The preflight allows the methods GET, POST, PUT, PATCH, DELETE, and OPTIONS, and the request headers Content-Type and Authorization. The rate-limit headers and Retry-After are exposed to script through Access-Control-Expose-Headers.
Public media routes (avatars, icons, banners, and /files/{key}) answer every origin with Access-Control-Allow-Origin: *, so images can be loaded from anywhere.
A bot or server-side client is not a browser and is not affected by CORS.
Error response object¶
A failed request returns a JSON body with a stable error code and a human-readable message.
Structure¶
| Field | Type | Description |
|---|---|---|
| error | string | The HTTP status line, for example "403 Forbidden" |
| code | string | Stable machine-readable error code |
| message | string | Human-readable description of the failure |
| errors? | array[validation error object] | The failing fields, on an INVALID_FORM_BODY response that has field detail |
A client MUST branch on code and the HTTP status, never on message. Messages are written for end users, are currently in Turkish, and can change at any time.
Example¶
{
"error": "403 Forbidden",
"code": "FORBIDDEN",
"message": "Kendi yetkilerinizi değiştiremezsiniz"
}
Some operations add further top-level members with structured detail, and document them. For example, login returns IP_AUTHORIZATION_REQUIRED with ticket, email, and resend_available_in, and a rate-limit denial carries global and retry_after. A client MUST ignore members it does not recognise.
Two older envelopes
A few account-security routes (the MFA login completions and the session routes on Authentication) report their own failures as {"error": "<CODE>", "message": "..."}, with the code in error and no code member. The OAuth2 routes put the OAuth2 error name (invalid_grant, invalid_client, ...) in message. Both are called out on their pages.
Validation error object¶
Each entry identifies one failed input field.
Structure¶
| Field | Type | Description |
|---|---|---|
| path | string | The request field that failed, as a dot-joined path such as embeds.0.title |
| code | string | Stable machine-readable validation code |
| message | string | Description of the failure |
Example¶
{
"error": "400 Bad Request",
"code": "INVALID_FORM_BODY",
"message": "Kanal sesli kanal olmalı",
"errors": [
{"path": "channel_id", "code": "CHANNEL_MUST_BE_VOICE", "message": "CHANNEL_MUST_BE_VOICE"}
]
}
In an errors entry, message currently repeats the code. Many validation failures return INVALID_FORM_BODY with a top-level message only and no errors array. Errors lists the codes.
Resource pages¶
| Page | Covers |
|---|---|
| Errors | The error envelope and the error code registry |
| Deployment availability | Features that depend on how the instance is configured |
| Instance | Instance discovery: endpoints, features, limits, public configuration |
| Authentication operations | Registration, login, MFA completion, passkeys, SSO, password recovery, IP authorisation, sessions, desktop handoff |
| Gateway | Bot discovery of the Gateway URL and session start values |
| OAuth2 | Authorisation, consent, token exchange, introspection, revocation, granted authorisations |
| Applications | Applications, bot accounts, client secrets, bot tokens, public lookup |
| Connections | Linked external accounts: domains and OAuth providers |
| Users | The user object and shared user enumerations |
| Current user | The current account and its profile |
| User settings | Account, notification, and privacy settings |
| User settings Protobuf | Structured client preference messages |
| Email and password changes | The code-verified credential change flows |
| Multi-factor authentication | TOTP, backup codes, passkeys, sudo mode |
| Phone verification | Phone number verification |
| Relationships | Friends, friend requests, blocks |
| User notes | Private notes about other users |
| Private channels | Direct messages and group DMs |
| User content collections | Mentions, saved messages, bulk deletion |
| Gift inventory | Gift codes created by the current account |
| Data harvests | Account data export |
| Read states | Read markers and mention counts |
| Memes | The saved media collection |
| GIFs | GIF search through the instance provider |
| Themes | Shareable custom CSS themes |
| Channels | Channel objects, recipients, permission overwrites |
| Calls | DM call ringing and control |
| Streams | Go Live streams and previews |
| Entrance sounds | Voice entrance sounds |
| Messages | Messages, attachments, embeds, pins, reactions |
| Guilds | Guild objects and lifecycle |
| Guild channels | Channel listing, creation, ordering |
| Guild members | Member objects, roles, voice moderation |
| Guild member search | Member search and filtering |
| Guild moderation | Bans and timeouts |
| Guild emojis | Custom emojis |
| Guild stickers | Custom stickers |
| Expressions | Emoji and sticker lookups across guilds |
| Guild audit logs | Audit log entries and filters |
| Roles and permissions | Permission flags and role objects |
| Discovery | Public guild listings |
| Invites | Invite lookup, creation, acceptance |
| Webhooks | Webhook management and execution |
| Search | Message search |
| Unfurl | Link preview resolution |
| Billing | Checkout through Polar |
| Premium | Premium plans and subscription state |
| Gifts | Gift code lookup and redemption |
| Experiments | Feature flags for the current account |