API documentation
The Telma REST API is served at https://api.textos.ca. This page covers the concepts
(authentication, multi-domain, roles, errors). The full list of operations, with their
parameters and access rules, is in the API reference, generated from the
code. The same information is available as OpenAPI on /docs (Swagger UI) and
/static/swagger.yaml.
URL prefixes:
| Prefix | Content |
|---|---|
/auth |
login, registration, profile, OAuth |
/api/v1 |
everything else (conversations, teams, organizations, domains, billing, ...) |
/webhook |
URLs called by SMS providers (outbound webhooks are described below) |
/docs |
Swagger UI |
Authentication
JWT token
POST /auth/login returns an access token and a refresh token. Send the access token in
the Authorization header:
When it expires, POST /auth/refresh (with the refresh token in the same header) returns
a new access token and a new refresh token, which replaces the old one (still accepted
for 60 seconds). POST /auth/logout, with the refresh token, ends the session: the refresh
token and the access tokens of that session that are still valid are refused at once. Changing the
password or deactivating the account immediately invalidates every token issued before.
The tokens of one sign-in form a family (claim fam, kept by the rotation). A refresh token
that was already replaced and is presented again after its 60 seconds of grace is a copy (stolen or
replayed token): the whole family is revoked, the newest token and the access tokens included, and
the user must sign in again. Other sessions of the same account are not touched. A client must
therefore never replay an old refresh token: it keeps the last one it received.
Session length and the remember option
The access token lasts 1 hour. The refresh token lasts 30 days and is renewed at each
POST /auth/refresh. POST /auth/login accepts remember (boolean, true by default): with
"remember": false the refresh token lasts only 12 hours (adjustable with JWT_REFRESH_SHORT_HOURS
on the server), still renewed at each refresh, so the session ends after 12 hours without a
refresh. The choice is kept by the rotation of the token and by the new tokens of
PUT /auth/me/password and PUT /auth/me/email. Without remember, nothing changes from before
(30 days).
Signing in without knowing the domain (any_domain)
A client that does not know the white-label domain of the account (the mobile app, when the
person did not type another domain) sends "any_domain": true to POST /auth/login. An e-mail
address has a single account on the whole platform; once the password is accepted, the answer
also carries domain: {id, name, host}, the domain the account works in: the domain of
X-Frontend-Host when the account works there, otherwise the only domain it works in (with
several, the one of its current organization). The client then sends host as
X-Frontend-Host for the rest of the session. A failed sign-in is answered exactly as without
the option (same 401, same budgets, same delay): it tells nothing about whether an address
has an account. The option is ignored on a request that carries X-API-Key.
User profile
GET /auth/me (and every response that carries a user: login, registration, OAuth,
/api/v1/users) returns:
| Field | Meaning |
|---|---|
name |
display name; always "first last" once either part is edited |
first_name, last_name |
first and last name (deduced from name for an older account) |
phone |
null, or {country, number, verified_at}: number in E.164 (+15145550123), verified_at null until the number was proven by SMS |
timezone |
IANA name (America/Toronto), null until chosen |
language |
fr or en |
permissions |
former permission names (view_team_conversations, send_sms…) the user's platform permissions or profiles in the current organization amount to, then those granted to the account (support); the permissions of the profiles are in organization_permissions (see Roles and permissions) |
role |
agent, manager, admin, domain_admin or super_admin: a copy derived from the profiles and the platform permissions, kept for older clients; it decides nothing |
platform_permissions, organization_permissions, profile_ids |
GET /auth/me only: the account's platform permissions, the permissions in the current organization (all of them for an admin of the platform or of its domain) and the profiles of the membership there |
mfa |
GET /auth/me only: {enabled, methods, totp, passkeys, backup_codes_left, required, reason, deadline, enforced}, the state of the account's second factor |
PATCH /auth/me edits name, first_name, last_name, language, timezone, phone
(unverified) and default_organization_id. A user can neither grant themselves permissions (403
forbidden) nor change their profiles or role. Granted permissions (support) are set with
PUT /api/v1/users/<id> (permissions), reserved to super_admin and domain_admin. A phone
number must belong to a country the domain accepts: Canada and the United States by default, other
countries (international +... format and country required) if the domain setting
settings.verification.phone_countries lists them. An invalid number gets 400 invalid_phone.
Session opened by an administrator
A member with the Admin profile of the organization (every permission), an admin of its domain or a super admin may open a session as a
member ("Sign in as"), without their password:
POST /api/v1/organizations/<org_id>/members/<user_id>/impersonate, with a session (never an API
key, never from a session itself opened that way; 20 per hour). Every organization of the member must
be administered by the caller (a domain admin only by a super admin, never a super admin, never
oneself: otherwise 403 forbidden or 403 member_of_other_organizations). The answer has the shape
of POST /auth/login: a session of the member, short (12 hours without a refresh, like
remember: false), whose user carries impersonated_by {id, name, email} (the administrator), also
in GET /auth/me. It does not count as a sign-in of the member, ends when the administrator's account
is deactivated, is journaled (audit_logs, impersonation_started) and may neither change the
password, e-mail or phone of the account (nor ask for a verification code), nor delete it, nor create
an API key: 403 impersonation_forbidden.
Password
PUT /auth/me/password {current_password, new_password} replaces the old
PATCH /auth/me {password} (deprecated, it now requires current_password too). The rule: 8 to 128
characters (72 UTF-8 bytes at most), at least 2 kinds among lower case, upper case, digits and
other characters, not a very common password, not the e-mail address or its local part, and
different from the current password (400 validation_error, field: new_password). A wrong
current_password gets 401 invalid_password. Every other session ends (token_version); the
route returns new access_token and refresh_token that keep the current session open: the
client must store them in place of the old ones. 10 attempts per hour per user.
POST /auth/forgot-password {email} always answers 200 with the same message and sends the link
(valid 24 hours) after the answer, through the SMTP server of the account's domain: the time of the
answer does not tell whether the address has an account. A new request does not kill the link
already received: up to 5 links are valid together, the oldest goes out at the sixth.
POST /auth/reset-password {token, password} follows the same password rule as
PUT /auth/me/password (400 validation_error, field: password), takes the link before the
password changes (two simultaneous requests cannot both succeed), ends every session and cancels
the account's other links. Tokens are stored as a SHA-256 digest only.
Verification by code, e-mail and phone
Changing the e-mail address (the sign-in identifier) or the phone number requires proof that the user controls the new address or number:
POST /auth/verifications{channel: "email"|"sms", target, purpose?, country?}sends a 6-digit code (valid 10 minutes, stored hashed) and answers202{verification: {id, channel, purpose, target_masked, expires_in, resend_after}}. The e-mail goes through the SMTP server of the user's domain, the text through that domain's SMS provider, from a number of its pool that no customer holds (or the one named by the domain settingsettings.verification.sms_from); with no usable number:503 sms_unavailable.country(ISO code of the country) gives the country of ansmstarget; it is deduced from the area code for a North American number.POST /auth/verifications/<id>/confirm{code}answers{verification: {id, verified: true}, verified_token, expires_in}. Theverified_tokenis single use and valid 10 minutes.PUT /auth/me/email{email, password, verified_token}orPUT /auth/me/phone{country, number, password, verified_token}apply the change. An e-mail change ends the other sessions and returns new tokens like a password change, cancels pending reset links and warns the former address; a phone change setsphone.verified_at.
A free sign-up whose address is not proved (user.email_verified false) proves it with
purpose: "email_verify" (no target, or its own address; 409 already_verified once proved) then
/confirm: the answer carries email_verified: true and credit_granted when a starting credit was
waiting (see Registration).
Anti-abuse: 60 seconds between two codes for one target, at most 3 codes per hour per user and
target, 10 per user and 6 per mailbox (all users: the spellings of one mailbox, with a +tag or,
at Gmail, with dots, count as one), 5 attempts per code. Beyond that:
429 rate_limited with retry_after. Errors specific to these routes:
| HTTP code | error |
Meaning |
|---|---|---|
| 400 | invalid_code |
wrong code (with attempts_left), or verified_token unknown, already used or proving another target |
| 400 | code_expired |
code or verified_token older than 10 minutes |
| 400 | verification_required |
verified_token missing |
| 400 | invalid_phone |
number or country not acceptable |
| 401 | invalid_password |
wrong current password |
| 409 | email_in_use |
address already belongs to another account |
| 409 | already_verified |
verification already confirmed (ask for a new code) |
| 429 | too_many_attempts |
5 attempts used (ask for a new code) |
| 429 | rate_limited |
too many codes requested |
| 502 | send_failed |
the mail server or the SMS provider failed |
| 503 | email_unavailable |
no SMTP server for the domain |
| 503 | sms_unavailable |
no SMS number or provider to send the code with |
API key
For programmatic access, an API key replaces the JWT:
A key is created with POST /api/v1/keys and the JWT of a member holding the api_keys.manage
permission (Admin profile): a request authenticated by a key gets 403 there, so a key never creates another one. The
body must be a JSON object with a non-empty name; team_id, when given, is the identifier (a
string) of a team you reach: otherwise, 400 validation_error. A key that is not limited to a team
has its creator's rights: it is only created with the owner's current password
(current_password: 400 validation_error when missing, 401 invalid_password when wrong; an
account without a password, signed in with OAuth, gives none), 10 attempts per hour.
Every key expires: expires_in_days (an integer from 1 to 365, 90 by
default) sets its expiry date (expires_at), after which it gets 401; a new one must then be made.
In the application, these keys are managed under Settings → Integrations → Administration keys. A personal key is revoked when its
owner changes the password or the e-mail address, resets the password or is deactivated. The secret is shown only once, at creation, in the key field of the 201 answer ({api_key, key, message, warning}: api_key describes the key, without its secret; an integration returns its own in api_key). An unknown or revoked key, the old key of an integration after regeneration, or the key
of a disabled or deleted integration gets
401 {"error": "unauthorized", "message": "Invalid API key"}, except on the routes closed to every
key (below), which answer 403 as soon as an X-API-Key header is present.
There are two kinds of keys:
- a regular key (created without
team_id) has the rights its creator's profiles give, judged as for their session, but never their platform permissions: the key of aplatform_adminordomain_adminaccount only has the permissions of its own profiles in its current organization (the User profile when it is not a member there), neither the platform nor a domain (their session keeps them); - a restricted key is limited to one team (
team_idat creation), or is the key of an integration. It only has the rights of the User profile on the teams it reaches, whatever the profiles of its creator: read conversations and send, administer nothing. Access to a team is still judged with the creator's current rights: if they lose the team, the key loses it too.
A restricted key gets 403 on every route that requires more than the permissions of the User profile (for example the
list of a team's closed conversations, GET /api/v1/teams/<team_id>/archives), and on the routes that concern the whole organization or its creator's
account: billing, organizations, voice, push notifications, free trial and account state, and
GET /api/v1/phone-numbers/unassigned. The body is then
{"error": "forbidden", "message": "API key not allowed on this route"} (a few checks specific to a
route keep their own message).
Some routes refuse every key, regular or restricted, with that same body. For the integrations
(/api/v1/integrations*), the billing of a domain (/api/v1/domains/<id>/billing-settings,
payment-setup, promo-codes, payments, monthly-fees, subscription-settings, billing-overview,
organizations/<org_id>/subscription), the profiles and platform permissions (/api/v1/permissions,
/api/v1/organizations/current/profiles*, /api/v1/organizations/current/members/<user_id>/profiles,
/api/v1/domains/<id>/profiles*, /api/v1/users/<id>/platform-permissions), the second factor and the
passkeys (/auth/mfa*, /auth/webauthn/*) and the internal API of the voice servers (/api/v1/internal/voice/*,
reserved to their service token), that 403 answers as soon as an X-API-Key header is present, even
an invalid one. For the search, purchase and management of numbers (prices, release, restoration,
update), the call codes of the voice service, the personal voice routes
(/api/v1/voice/my-endpoints*, GET /api/v1/voice/call-info, PUT /api/v1/voice/call-info/recording*,
GET /api/v1/voice/directory, POST /api/v1/voice/diagnostics) and
the creation of a key, it answers a valid key; an unknown or revoked key gets 401 there, as anywhere
else.
No key, not even a regular one, gives another way to sign in. That same 403 answers, as soon as an
X-API-Key header is present (even an invalid one), to what would create or strengthen an access:
POST /api/v1/users(creating an account, with or without a password),POST /api/v1/invitationsandPOST /api/v1/invitations/<id>/resend(sending an invitation's e-mail again);POST /api/v1/invitations/accept(accepting an invitation: only the session of the invited account);POST /api/v1/organizations/<id>/members/<user_id>/impersonate(opening a session as a member, see Session opened by an administrator);PUT /api/v1/users/<id>when the body containspassword, whoever the user (the key's creator included); your own password is only changed withPUT /auth/me/passwordanyway (403 elsewhere, even with a session);- a body that gives the
adminrole (hence the Admin profile) or a platform role:PUT /api/v1/users/<id>,PUT /api/v1/users/<id>/role,PUT /api/v1/organizations/<id>/members/<user_id>; POST /api/v1/domains/<id>/adminsandDELETE /api/v1/domains/<id>/admins/<user_id>;- changing the SMTP and OAuth settings (
PUT /api/v1/domains/<id>/settings/smtpand its testPOST .../settings/smtp/test,PUT /api/v1/domains/<id>/settings/oauth/<provider>,PUT /api/v1/system/smtp,PUT /api/v1/system/oauth/<provider>), which carry the password reset links and decide who may sign in, and the accounts that must have a second factor (PUT /api/v1/domains/<id>/security-settings); PUT /api/v1/domains/<id>when the body containshostnamesorname: the domain's hostnames give the address of the links of its sign-in emails.display_nameandcapabilitiescan still be changed with a regular key;- the routes that reach the whole domain:
PUT /api/v1/domains/<id>/account-settings,PUT /api/v1/domains/<id>/settings/payment-providers/<provider>,PUT /api/v1/domains/<id>/settings/default-payment-provider,PUT /api/v1/domains/<id>/sms-providers/<provider>,POST /api/v1/domains/<id>/webhook/regenerate, credit added by hand (POST /api/v1/billing/admin/add-credits,POST /api/v1/domains/<id>/organizations/<org_id>/credit) and the outbound call rules of an organization (PUT /api/v1/domains/<id>/organizations/<org_id>/voice-outbound); - the SMS provider accounts and the voice trunks, of the platform as of a domain (creation, update,
deletion, secrets, number inventory, allowed domains:
/api/v1/system/sms-accounts*,/api/v1/domains/<id>/sms-accounts*,/api/v1/system/voice-trunks*,/api/v1/domains/<id>/voice-trunks*in writing).
The exact list, route by route, is generated from the code: see API keys: closed routes in the reference.
These operations stay open to a session (JWT). A key can therefore neither create another key nor obtain a session that would create one.
A key never goes through the second factor: it is a separate secret, created by a
session (which may have required the second factor). The authenticated routes of /auth (GET /auth/me
included) only accept a JWT: with a key alone, they answer 401 (403 for /auth/mfa* and /auth/webauthn/*,
closed to every key).
GET /api/v1/teams returns to a restricted key exactly the teams it reaches in the current
domain, without their webhook_secret. With any key, GET /api/v1/phone-numbers only lists the
numbers assigned to a team the key reaches. For a restricted key, these two lists and
GET /api/v1/phone-numbers/<id> start from the key's teams, whatever organization its creator is
currently working in within the app.
Since there is no session, some routes (for example GET /api/v1/teams) accept an
organization_id parameter to select the organization; with a restricted key, it only filters its
teams (never a 403). With a regular key, as with a JWT, it must be an organization the key's
creator belongs to or, if they are a domain_admin, an organization of their domains (a
super_admin reaches them all): otherwise 403, whatever X-Frontend-Host. GET /api/v1/teams
only gives a team's webhook_secret to the members holding teams.provider in its organization (Admin and
Manager profiles), and to those who administer it (platform_admin, domain_admin of its domain). X-Frontend-Host (see
Multi-domain) is optional with a key; when present, it must be the host of the
organization's Telma app, otherwise the key sees no team (empty lists, 403 on a team).
Keep the key on your server: see Security.
Registration
By default there is no open registration: POST /auth/register requires an invitation token.
A domain can open free sign-up (setting signup.enabled, closed by default):
GET /auth/signup/config (public) tells whether it is open and which captcha to show, then
POST /auth/signup {email, password, first_name, last_name, phone?, language, accept_terms,
captcha_token?} creates the account, its organization and its default team. The e-mail address is
not proved at sign-up: the domain's starting credit (initial_credit) is therefore granted, once,
only when the address is verified (purpose: "email_verify", see
Verification by code) or changed to a verified one. See
Account: sign-up, trial, deletion, notifications.
With an invitation: The
token comes either from an organization invitation (the user joins that organization with
the invitation's profiles) or from a domain administrator invitation (the user becomes
domain_admin of the domain, with no organization of their own).
GET /api/v1/invitations/verify/<token> shows the destination before the account is created. The
password chosen follows the strength rule of PUT /auth/me/password (400 validation_error,
field: password).
An invitation to an address that already has an account never adds that account to the
organization: the answer of POST /api/v1/invitations is the same (no profile), and the account owner
accepts it themselves, signed in, with POST /api/v1/invitations/accept {token} (the e-mail leads to
the page that does it). Only the session of the invited account accepts; a token for another
address gets 404.
Sign-in emails
The e-mails that carry a way to sign in (password reset, organization invitation, created or sent
again, and domain administrator invitation) go through the SMTP server of the domain of the account
concerned, and their link leads to that domain's first hostname that is not localhost. That
domain never depends on the request's X-Frontend-Host alone; when no domain fits, it is the
platform:
| Domain used | |
|---|---|
reset (POST /auth/forgot-password) of an account whose organizations (current one and memberships) are all in one domain |
that domain |
reset of a domain_admin who administers only one domain and has organizations in that one only, requested from that domain (X-Frontend-Host) |
that domain |
reset of a super_admin, of a domain_admin in the other cases, or of an account whose organizations span several domains, none, or an organization without domain |
none: the platform |
organization invitation (POST /api/v1/invitations, POST /api/v1/invitations/<id>/resend) |
the organization's domain (the platform if it has none) |
domain administrator invitation (POST /api/v1/domains/<id>/admins) |
the invited domain |
A domain without a configured SMTP server goes through the platform's, and a domain without a
production hostname gives a link to the platform's address. The platform is the environment of the
Telma server: SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, SMTP_FROM_EMAIL,
SMTP_FROM_NAME, SMTP_USE_TLS for the server, and FRONTEND_URL for the address of the links.
Without a configured server, nothing is sent. For a valid e-mail address,
POST /auth/forgot-password answers 200 with the same message, whether it matches an account or
not, and whatever the domain chosen.
A domain's administrators set its SMTP server and hostnames: they therefore never receive the link of an account their domain does not cover entirely.
Sign-in security
- Password guesses.
POST /auth/logincounts only the failures (401): 30 per 15 minutes and per e-mail address, then429 rate_limitedwithRetry-After, even with the right password (the budget bounds the guesses). When the client's address is known: 10 failures per 15 minutes per e-mail and address pair, and 100 per address; one address can therefore neither lock an account out for the others nor spray many accounts. The 4th and later failures of an account are answered more and more slowly (0.5 s, doubling at each failure, up to 4 s). A successful sign-in never counts. An unknown account, a service account or one without a password costs the same computation as a known account. - OAuth (Google, Microsoft, Apple).
GET /auth/oauth/<provider>?redirect_uri=returns{auth_url, state}: thestateis made and signed by the server (10 minutes, single use, bound to the provider, the domain and theredirect_uri) and the URL carries a PKCE challenge (S256). The client keeps thestate(sessionStorage), refuses a return whosestatediffers, and sends it with thecodetoPOST /auth/oauth/<provider>/callback(without it:400 oauth_error, nothing is sent to the provider). The account is the one linked to the provider identity (Googlesub, Microsoftid, Applesub). An address is linked to an existing account only when Google vouches for it (email_verified); Microsoft vouches for nothing, so it never links, nor applies a domain admin invitation, by address. An account is created only if the domain's free sign-up is open, in that domain. Every failure is the same400 oauth_errorwith a generic message. One person may link Google, Microsoft and Apple to the same account: an identity of another provider whose address is vouched for is added next to the first one (oauth_identities); another identity of the same provider with the same address is refused. Google and Microsoft always use the platform's credentials: a domain only turns them on (PUT /api/v1/domains/<id>/settings/oauth/google|microsoft{enabled}, any other field is ignored). - Apple (Sign in with Apple). Same rules, with three differences:
GET /auth/oauth/apple?redirect_uri=takes the web page the browser must come back to (an address of a known domain); Apple sends the browser back with a form (form_post, required to get the name and the address) to the API'sPOST /auth/oauth/apple/return, which sends it on (303) to that page withcode,stateand, the first time only,user(the name); the page sendscode,stateandusertoPOST /auth/oauth/apple/callback. What Apple says about the person is theid_tokenof the exchange, checked with Apple's public keys (signature, issuer, client ID, expiry and thenoncebound to thestate). Apple vouches for the address, a relay address (…@privaterelay.appleid.com) included. Credentials: the domain's (PUT /api/v1/domains/<id>/settings/oauth/apple:client_id= Services ID,team_id,key_id,private_key= the .p8 key), each one missing taken from the platform's configuration; Apple is offered (GET /auth/oauth/providers) only when all four are known. The client secret is a 5-minute ES256 token signed with the key, made at each exchange. - Second factor. An account may have an authenticator app (TOTP, 6 digits) and passkeys (WebAuthn:
fingerprint, face, device PIN, USB or NFC security key), plus 10 single-use backup codes (given once,
stored hashed). With a second factor, the right password (or OAuth) does not give the session: the
answer is
{mfa_required: true, mfa_token, methods, expires_in}. Themfa_token(5 minutes, single use, dead when the password changes, never a session token) goes with a code or a passkey assertion toPOST /auth/mfa/verify(method=totp,backup_codeorwebauthn; passkey options:POST /auth/mfa/webauthn/options); 10 failures per 15 minutes per account. A TOTP code works once. The session then opened carries the claimmfa: true. - Sign-in with a passkey.
POST /auth/webauthn/login/optionsthenPOST /auth/webauthn/login: no address nor password, the passkey tells the account; the device must have verified the person (fingerprint, face, PIN), so the sign-in counts as a second factor. A passkey is bound to the site it was created on (theOriginof the request, which must be a host of a known domain). The mobile app sends noOriginbutX-Client-App: iosorandroidwithX-Frontend-Host: the passkey is then the one of that host's site (the site declares the app in/.well-known/apple-app-site-associationand/.well-known/assetlinks.json), and a passkey created on the site also works in the app. Management:GET /auth/mfa,POST /auth/mfa/totp/setup|confirm|disable,POST /auth/mfa/backup-codes,POST /auth/webauthn/register/options,POST /auth/webauthn/register,PATCH /auth/webauthn/credentials/<id>,POST /auth/webauthn/credentials/<id>/remove. Removing a factor or renewing the codes takes a code (TOTP or backup), the password only for an account with passkeys alone. Closed to API keys and to sessions opened as a member; every change is journaled (mfa_factor_added,mfa_factor_removed,mfa_backup_code_used). - Administrators' second factor: recommended, soon mandatory. It is expected from platform and
domain admins, and from the organization admins (
members.admin) of the domains that ask for it (PUT /api/v1/domains/<id>/security-settings {mfa_organization_admins}; reading it also givesmfa_enforced, false while the obligation is not turned on). Today it is only recommended: such an account signing in without a second factor gets its session withmfa_setup {required: true, reason, deadline: null}(the interface shows a "will become mandatory" banner) and nothing is ever refused. When the platform turns the obligation on, there will be a grace period of 14 days (MFA_GRACE_DAYS) from the first time the account was seen without one: during it,mfa_setupcarries thedeadline; after it, the password only gives an enrollmentmfa_token(mfa_enrollment_required, 15 minutes), to put in theX-MFA-Tokenheader of the routes that add a factor (/auth/mfa/totp/*,/auth/webauthn/register*): the route that adds the first factor answers with the session. A session opened without a second factor will no longer be renewed (POST /auth/refresh: 401) once the grace period is over, and the last factor of such an account will not be removable (409 mfa_required). A client should therefore already handlemfa_requiredandmfa_enrollment_required. - Headers. Every API answer carries
X-Content-Type-Options: nosniffandReferrer-Policy: no-referrer;/auth/*answers also carryCache-Control: no-store. The content security policy (CSP) of the pages belongs to the frontend's web server, not to the API. - CORS. The API reflects an origin only if its host name is configured: the hosts of the domains
(database) or
CORS_ORIGINS.localhost,127.0.0.1,10.x,192.168.xand*.textos.caare honored in development and testing only (FLASK_ENV). - Journal. The writes made with
X-Admin-Organizationare journaled one by one; the reads are journaled as one entry per administrator, organization and quarter of an hour (admin_read, with the number of reads).
The routes of the second factor and of the passkeys answer {error, message} with these codes:
| HTTP code | error |
Meaning |
|---|---|---|
| 400 | proof_required |
removing a factor or replacing the backup codes without code (nor password for an account that only has passkeys) |
| 400 | invalid_code |
wrong TOTP or backup code while managing; at POST /auth/mfa/verify, a wrong code answers 401 invalid_code |
| 400 | origin_not_allowed |
Origin (or, for the app, X-Frontend-Host with X-Client-App) that is not a host of a known domain |
| 401 | invalid_mfa_token |
mfa_token or X-MFA-Token unknown, expired, already used, or dead since the password changed |
| 401 | unknown_credential |
unknown passkey, or signature refused |
| 401 | invalid_password |
wrong password given as proof |
| 401 | unauthorized |
neither a session nor a valid X-MFA-Token on a management route |
| 403 | second_factor_required |
adding a factor to an account that already has one takes a session that proved a second factor (mfa: true) |
| 403 | impersonation_forbidden |
session opened as a member |
| 404 | not_found |
unknown passkey (rename, remove) |
| 409 | mfa_required |
removing the last factor of an account that must have one (only once the obligation is on) |
| 409 | already_enabled, not_started, mfa_not_enabled |
authenticator app already on, confirmation without setup, backup codes asked without a second factor |
| 409 | already_registered, too_many_passkeys, no_passkey |
passkey already registered, already 20 passkeys on the account, no passkey of the account for this site (POST /auth/mfa/webauthn/options) |
Multi-domain
Every whitelabel site is a domain: app.telma.ca, other.telma.ca, ...
The API resolves the domain from the X-Frontend-Host header, falling back to Host:
The current domain determines:
- which organizations are visible and manageable;
- the configuration in effect (SMTP, OAuth, SMS providers, branding, payment), except for the sign-in emails, which follow the domain of the account concerned;
- the phone numbers and providers available.
Mobile applications must send this header.
Current and default organization
A user can belong to several organizations across several domains.
GET /api/v1/organizations/current returns the active organization for the visited
domain: when the user's active organization belongs to another domain, the API switches
to their default organization for this domain, or else to the first organization of the
domain they can access.
The default organization is set per domain with PATCH /auth/me and the
default_organization_id field; the organization must belong to the visited domain, and null
(or an empty string) clears the default of that domain. The
user payload exposes default_organization_id (the default for the current domain) and
default_organization_ids (all domains).
Roles and permissions
Since 2026-10-16, rights come from profiles and permissions (the former roles agent, manager,
admin stay in the responses, deduced from the profiles, during the transition; they no longer decide anything:
only permissions count, and the role of a platform account is super_admin or domain_admin after its platform
permissions).
- Platform permissions, held by the account:
platform_admin(the whole system: the formersuper_admin),domain_admin(the domains ofdomain_ids: all their organizations),support. They are given withPUT /api/v1/users/<id>/platform-permissions{permissions, domain_ids?}(platform_adminonly; the account's sessions end). - Permissions of an organization: catalogue
GET /api/v1/permissions(messages.read,messages.send,messages.archive,messages.delete,teams.all,teams.manage,teams.hours,teams.provider,members.manage,members.admin,metrics.team,metrics.all,numbers.manage,numbers.buy,billing.view,billing.manage,integrations.manage,api_keys.manage,organization.settings,voice.use,voice.calls.all,voice.routing,voice.endpoints,voice.blacklist,voice.recordings). They come from the member's profiles, which add up. - Profiles: two built-in profiles never changed,
builtin:admin(every permission) andbuiltin:user(messages.read,messages.send,metrics.team,voice.use); those of a domain (/api/v1/domains/<id>/profiles, managed by the platform and the admins of that domain, offered to its organizations) and those of the organization (/api/v1/organizations/current/profiles: read withmembers.manageormembers.admin, write withmembers.admin; a profile still held by a member is not deleted,409 profile_in_use). An organization that had managers received a « Manager » profile with the exact rights of the former role. A member's profiles are set withPUT /api/v1/organizations/current/members/<user_id>/profiles(members.admin;409 last_adminwhen nobody would be left withmembers.admin,402 plan_limitbeyond the users of the plan) and an invitation can carry some (profile_idsofPOST /api/v1/invitations). Changing the permissions of a profile is judged the same way for everyone who holds it:402 plan_limitwhen members would start using the messages beyond the plan,409 last_adminwhen an organization would have nobody left withmembers.admin, and nothing is changed. For a domain profile, the refusal names the organizations concerned (organizations:{id, name, error, limit, plan, upgrade, current}). metrics.allopens the metrics of every team;metrics.teamalone, those of the team one is placed in (another team: 403). The list of members (GET /organizations/current/members,membersofGET /organizations/<id>) requiresmembers.admin.- Acting on a member (reading them, changing their team, role or profiles, removing them, their API keys,
their statistics):
members.adminin the organization, ormembers.managefor a member of one's team who has neithermembers.managenormembers.admin, and only when one also holds each of their permissions there: nobody acts on someone who can do more. There is no role hierarchy. Likewise, one only gives (a role, the profiles of a member or of an invitation) permissions one holds (403 forbidden,fieldroleorprofile_ids); withmembers.managealone, nevermembers.managenormembers.admin. Widgets and integrations:integrations.manage; a team's inbound secret:teams.provider. GET /auth/megivesplatform_permissions,organization_permissions(current organization) andprofile_ids;GET /api/v1/organizations/mygivespermissionsfor each organization.
A route judges the permission in the organization of the resource (team, conversation, message), else in the
current organization. A platform_admin or domain_admin account passes these checks (the routes of a domain
and the administration context bound its reach); an account without a membership in
its current organization (the service account of an integration) has the User profile; a restricted API key
(limited to a team, or of an integration) has the permissions of the User profile, a regular key those of its
creator without their platform permissions (see API key). The routes of the platform and of the
domains ask for a platform permission (domain_admin: only for their domains). Without the permission:
403 forbidden. The permission of each operation is given in the reference.
Voice (VoXoS)
The /api/v1/voice/* routes only serve organizations that carry the voxos capability (403
voice_not_enabled otherwise) and only show the resources of the current organization. Rights
depend on the permissions held in the organization (profiles); the reference gives each route's:
| Resource | Read (GET) |
Write |
|---|---|---|
numbers (/voice/numbers), flows (/voice/flows), groups (/voice/groups), sounds (/voice/sounds and a sound's file), standard prompts of the voice server (/voice/system-prompts*), limits and prices (/voice/limits, /voice/rating/*) |
voice.use (speech synthesis GET /voice/sounds/tts: voice.routing) |
voice.routing |
calls (/voice/calls), statistics (/voice/stats, with_stats of /voice/numbers) |
voice.use: the calls of their own endpoints (made or rung); with voice.calls.all: every call; endpoint_id: only the calls of that endpoint, which must be the user's (404 otherwise, even with voice.calls.all) |
— |
voicemail (/voice/voicemails) |
box of a number: voice.calls.all or voice.routing, and the member one of whose endpoints the number's routing rings (directly, through a call group, one of its stages or its fallback, or through a call flow, goto included); box of an endpoint: its user and voice.routing; standalone box: its members and voice.routing |
mark listened: whoever may hear it; delete: voice.routing, or a listener of an endpoint or standalone box |
voicemail boxes (/voice/mailboxes, capability voicemail_boxes) |
the boxes the user listens to (all of them with voice.routing) |
voice.routing |
SIP endpoints (/voice/endpoints, SIP passwords included) and diagnostics reports (GET, DELETE /voice/diagnostics*) |
voice.endpoints |
voice.endpoints |
blacklist (/voice/blacklist) |
voice.blacklist |
voice.blacklist |
recordings (/voice/calls/<id>/recording, /voice/recordings) |
voice.recordings |
voice.recordings |
music on hold (/voice/moh, tracks /voice/moh/tracks*, playlists /voice/moh/classes*, choice /voice/moh/settings, /voice/moh/numbers/<id>) |
voice.use (the free library of the platform, the tracks and playlists of the organization, the choice) |
voice.routing |
/voice/provision (the user's SIP account), /voice/my-endpoints (their endpoints: do not disturb, forwards, follow me), /voice/call-info, the recording set from the app (/voice/call-info/recording*), the directory (/voice/directory) and the upload of a diagnostics report (POST /voice/diagnostics) |
the user themselves (voice.use, except /voice/provision), for the endpoints assigned to them |
the user themselves |
Music on hold. GET /voice/moh gives the free library of the platform (author and license of each track), the
tracks and playlists of the organization, its choice and that of its numbers. An uploaded track (POST
/voice/moh/tracks, multipart) requires rights_confirmed: true (broadcast rights: who and when are kept); a playlist
(/voice/moh/classes) gathers tracks of the library and of the organization. The choice
(PUT /voice/moh/settings {class_id, ringing}, PUT /voice/moh/numbers/<id> for a number, null = the organization's): the playlist played
when an extension holds the call and, with ringing: true, instead of the ringback tone while phones ring; without a
choice, the default music of the platform. From an extension, 602 plays the music of the organization.
Audio formats. All stored audio is MP3: the sounds (uploaded as wav, mp3, m4a, ogg or webm, recorded in the browser
or made by text-to-speech, converted at their creation; original_extension keeps the format received), the music on
hold, the voicemail messages and the call recordings.
History of an endpoint (app). GET /voice/calls?endpoint_id=<id> only gives the calls of that endpoint (made,
or that rang it); the endpoint must be assigned to the user (404 otherwise, whatever their permissions). Each call
then carries mine {direction, number, name, answered}: the call as the endpoint lived it (outbound or inbound,
the other party, and whether this endpoint answered it).
Recording from the app. The user sets the recording of the calls their endpoint makes
(PUT /voice/call-info/recording?endpoint_id= with {enabled}). The organization can impose it on outbound
calls with GET|PUT /api/v1/domains/<id>/organizations/<id>/voice-recording
({outbound_always, outbound_follows_number, recording_skip_allowed}, domain or platform admin, off by
default): its rules then win over the endpoint's setting. When the recording is imposed and
recording_skip_allowed allows it (the default), PUT /voice/call-info/recording/skip-next?endpoint_id=
with {skip} skips the recording of the next outbound call only (valid 15 minutes, used once); otherwise
403 recording_skip_not_allowed, or 409 recording_not_imposed when nothing imposes it.
GET /voice/call-info gives recording_detail (endpoint, imposed_by, skip_allowed, skip_next).
These changes are journaled.
Standard prompts (call flows). The play and menu steps of a flow accept, instead of a sound of the
organization (sound_id, which wins when given), system_sound: 1 to 6 prompts of the voice server played one
after the other, each a name of the catalogue GET /voice/system-prompts (the prompts offered with their fr
and en text; another name gives 400 validation_error, field = steps[i].system_sound), fr: or en: before the name
to fix its language, or an object {name, only: ["fr"]} played only when the call is in one of these
languages. GET /voice/system-prompts/<name>/audio?language=fr|en plays a prompt (catalogue only).
The dial and groupdial steps accept wait (ring or music: ringing or music on hold while the endpoints
ring; absent, the number's setting), and the menu step extension_dial: true (the caller dials the short
number of an endpoint of the organization; a key of the menu wins).
Devices per endpoint. Several devices can be connected to the same endpoint at once and ring together:
3 by default (platform setting), max_contacts from 1 to 10 on an endpoint (POST|PUT /voice/endpoints,
null = the platform's value); beyond that, the oldest registration is replaced.
SIP endpoints (/voice/endpoints*, /voice/my-endpoints*), provisioning and reading the diagnostics
reports also need the sip capability (see Capabilities). GET /voice/call-info and
POST /voice/diagnostics start from an endpoint assigned to the user, in any of their organizations
(404 otherwise). All the personal routes (GET|PUT /voice/call-info*, GET /voice/directory,
POST /voice/diagnostics, GET /voice/calls?endpoint_id=) start from such an endpoint (enabled, except for
the history) and judge voice.use, voxos and sip in the organization of THAT endpoint, not in the current
organization. The user also sets the recording of their endpoint with PUT /voice/my-endpoints/<id>/settings
(recording), as from the app. The cost details of a call (billing.costing) never leave the API. API keys limited to a team and integration keys never reach
/voice/*; the personal routes (/voice/my-endpoints*, /voice/call-info,
POST /voice/diagnostics) refuse every key.
Voice security (anti-fraud). The voice server applies to every call to the public network
(extension, forward, external member of a group, call code) the organization's limits, else the
domain's: allowed countries (Canada and the United States by default), premium numbers refused,
allowed hours for international calls (international_hours), loops, maximum cost of a call ($10),
maximum duration (4 h), spending limit over a sliding window ($10 in 5 minutes) and 10 simultaneous
calls. GET /voice/limits gives the organization what applies (limits) and outbound_suspension:
null, or {reason, detail, at} when its outbound calls are suspended (reason: spend = spending
limit, call_cost = a call reached the maximum cost, manual = an administrator, platform = the
master switch). During a suspension, outbound calls are refused (call codes with
403 outbound_suspended); inbound calls go on. Only a domain or platform admin lifts it:
POST /api/v1/domains/<id>/organizations/<id>/voice-outbound/resume (and .../suspend to suspend
by hand, with an optional note; both are logged). Settings: GET|PUT /api/v1/domains/<id>/voice-limits
(domain) and .../organizations/<id>/voice-limits (organization, empty = the domain's value). The
events (type: outbound_suspended, call_cost_reached, call_duration_reached,
refused_destination, refused_international_hours, refused_loop, refused_no_rate,
refused_source, new_country, endpoint_locked, night_volume, platform_outbound_suspended,
platform_outbound_resumed) are in GET /api/v1/domains/<id>/voice-security (with the suspended
organizations) and, for the super admin, GET|PUT /api/v1/system/voice-security (the master switch
outbound_suspended); each one is also e-mailed to the platform and domain admins (a suspension also
to the members of the organization holding organization.settings), at most once an hour for the same organization and type. An extension
may limit its outbound calls to networks (settings.allowed_networks, CIDR); after 20 wrong passwords
in 10 minutes it is locked (locked {until, at, failures}) and POST /voice/endpoints/<id>/unlock
frees it.
Capabilities
A capability is a service a domain offers and an organization may use. An organization holds
only a subset of the capabilities of its domain. A super admin or domain admin sets them with
PUT /api/v1/organizations/<id>/capabilities (the list replaces the previous one); the domain's
own are set with PUT /api/v1/domains/<id> (adding sip, call_codes or voicemail_boxes to a
domain is reserved to the super admin). Every route that returns an organization or a domain
(/organizations/current, /organizations/my, /domains/current...) shows the list.
| Capability | Opens |
|---|---|
textos |
SMS/MMS messaging |
voxos |
the voice service (/api/v1/voice/*) |
sip |
SIP endpoints (/voice/endpoints*) and softphone provisioning (/voice/provision); needs voxos |
call_codes |
call codes (legacy, without interface: no app uses them any more); needs voxos |
voicemail_boxes |
the voicemail boxes of the endpoints and the standalone boxes (/voice/mailboxes); needs voxos |
An unknown name, or sip, call_codes or voicemail_boxes without voxos in the same list, is refused:
400 validation_error (field: capabilities). Without sip, the routes that need it answer
403 with {"error": "capability_required", "capability": "sip", "message": ...} (and
voice_not_enabled without voxos). Removing sip from an organization disables its SIP
endpoints; they are enabled again when sip comes back. The custom routing action of a number is
only accepted for super admins and domain admins (403 forbidden for a member of the organization, even with voice.routing).
Administration context
POST /api/v1/organizations/switch writes the current organization on the user: it then holds for
all their devices and tabs. To administer an organization without becoming a member, a
super_admin or a domain_admin sends, on every request (JWT), the header
For that request only, the user is seen as working in that organization: organization_id is the
organization asked for, team_id is null, the platform permissions are kept. Nothing is written on the
user, and one tab per organization is possible. The routes are the usual ones
(/organizations/current, /teams, /users, /voice/*, /billing/*...).
GET /auth/me, GET /organizations/current and GET /organizations/my add
admin_context: true and admin_organization_id while the header is honored.
| Case | Answer |
|---|---|
user who is neither super_admin nor domain_admin |
403 forbidden |
domain_admin and an organization outside their domains |
403 forbidden |
| unknown organization or invalid identifier | 404 not_found |
| request authenticated by an API key (any key) | 403 forbidden |
| empty header, or request without a valid token | ignored (the route answers as without the header) |
Every write (POST, PUT, PATCH, DELETE) made with the header is journaled (collection
audit_logs): user, organization, method, path, status and date, with no request body and no URL
parameters. Successful reads (GET) are summed up in one entry per administrator, organization
and quarter of an hour (admin_read, with the number of reads). Socket.IO has its own administration mode
(auth.admin_organization, see Real time); the organization is judged with the domain of the host (X-Frontend-Host) like the rest of the
API, so a super admin administering an organization of another domain sends the host of that
domain.
Organization search
GET /api/v1/organizations (super admin or domain admin; current domain) accepts q (free text
searched in the organization name and in its owner's e-mail address, ignoring case and accents, 100
characters at most), skip and limit (50 by default, 100 at most; an invalid value is a
400 validation_error). Sorted by name. The answer is {organizations, total, skip, limit} where
total is the number of organizations after the filter. An account with platform_admin can add all_domains=true
to search every domain: each organization then carries domain_id, domain_name and
domain_host (without that permission: 403).
Account: sign-up, trial, deletion, notifications
Everything below is off or neutral by default and is set per domain with
GET/PUT /api/v1/domains/<id>/account-settings (domain admin): Textos and VoXoS do not change.
- Notifications:
GET/PATCH /auth/me/notificationsgives and changes what the account receives:email.updates,survey,purchase,login(an e-mail at every sign-in, off by default),low_balanceand its thresholdlow_balance_threshold(null: the organization's),push.sms,missed_call,voicemail. Security and service e-mails (password reset, verification codes, invitation, negative balance notices, end of trial, account deletion...) are always sent. All the code goes throughshould_notify(user, kind)(services/notification_preferences.py, which holds the catalogue of messages). - Free preferences:
GET/PATCH /auth/me/preferences, a small key/value store (50 keys, 8 KB) for the apps; anullvalue removes the key. - Deleting one's own account:
DELETE /auth/me {password, confirm: true}. An account that is alone in its organization closes it with itself (refused with409 balance_negativewhile the balance is negative); its numbers go back to the pool of the domain and its keys, integrations and SIP endpoints are closed. An account that shares its organization leaves it (refused with403 last_adminfor the last member with the Admin profile of an organization that has other members). All sessions end. During the grace period of the domain (account_deletion.grace_days, 10 days by default, 0 to delete at once), signing in again with the password cancels the deletion; then the account is deleted for good. Refused to platform accounts and in the administration context. - Free sign-up: see Registration. Protections: captcha (provider set per
domain), at most 5 accounts per client address and per day (only when that address is known, see
Rate limits; otherwise a wide shared ceiling of at least 100 accounts per day and
domain), disposable e-mail providers refused, 10 attempts per hour and e-mail address (20 per
known client address).
409 email_in_usetells that an address has an account: the sign-up form needs it, the captcha and these limits bound the probing. Answers:403 signup_disabled,409 email_in_use,400 captcha_failed,503 captcha_unavailable,429 rate_limited. - Free trial:
GET /billing/trialgives the state,POST /billing/trial/activate {country, type?, area_code?, city?}gives the organization a number of the domain pool fortrial.duration_daysdays (10 by default). It needs a verified phone (one phone number opens one trial per domain). Four e-mails to the members holdingbilling.manage(halfway, 2 days, 1 day, end); at the end the trial numbers go back to the pool. Reachingtrial.minimum_creditof balance converts the trial into a normal account.GET /organizations/current/statussums up the state of the account (active,suspendedortrial).
Plans (subscriptions)
A domain may offer plans to its organizations (GET|PUT /api/v1/domains/<id>/subscription-settings,
off by default): messaging plans (essential, team, enterprise, with included messages, users,
teams, numbers and widgets, max_users, max_teams), price tiers for extra messages, the phone
system (voice) and options. The organization reads and chooses its own with
GET|PUT /api/v1/billing/subscription (billing.manage to choose; body {messaging_plan, voice}, both
required: messaging_plan = essential, team, enterprise or null for none, voice = boolean) and
estimates a choice with GET /api/v1/billing/subscription/estimate?messaging_plan=&voice= (empty
messaging_plan = the current plan, none = no plan; an inactive plan gives 400 validation_error). The domain admin uses GET|PUT /api/v1/domains/<id>/organizations/<id>/subscription.
- Paid in advance from the credit. The plan is debited from the organization's credit on the
anniversary date, every month (statement
invoices.kind = statement, one line per item: plan, phone system, full and virtual users, numbers beyond those included, toll-free numbers, extra teams and widgets, recorded extensions). Seats are counted on the anniversary. Choosing a plan requires automatic recharge: without it,402 auto_recharge_required(not for the domain admin). - Pro rata. A dearer plan, or adding the phone system, applies at once: the difference is debited
pro rata of the days left in the period. A cheaper plan or a removal waits for the anniversary
(
pendingin the answer). - Quotas. Outbound messages count per segment (an MMS = 2), inbound ones do not count. Beyond the
included messages, each message costs the price of its tier by the month's volume. Minutes to Canada
and the United States draw on a shared pool (one allowance per full user), then are billed per
minute; the members holding
billing.manageare warned at 80%. Internal calls are never billed; international calls are billed at the domain's rate. The answer givesusage(the period's messages and minutes),seatsandnext_statement. - Limits. Beyond
max_users(members holdingmessages.readormessages.send, at invitation or when profiles change) ormax_teams(when a team is created):402 plan_limitwithlimit(usersorteams),plan,upgrade(the next plan, ornull) andcurrent(what is already counted: members using messages and pending invitations, or teams). The user limit is checked wherever a member gets messages: invitation and its acceptance,POST /users, a role change (PUT /users/<id>,PUT /organizations/<id>/members/<id>), a profile change, or a change to the permissions of a profile members hold; choosing a plan too small for the organization (first subscription or change, even deferred to the anniversary) is refused too, domain admins included. A domain that turns plans off shows no subscription (subscription: null) and bills none; they come back as they were when it turns them on again. A subscribed organization no longer pays the monthly fees of its numbers. - Lines. The phone system includes lines (calls at once: an external forward counts as two):
included_linespluslines_per_full_userper full user, with a marginburst_linesabove (by default as many as included, at least 2); the channel limit of the organization follows (source: subscriptioninGET /voice/limits), unless an administrator set one by hand. Nothing is bought: the peak of the period beyond the included lines is billed on the next statement (lineextra_line, also in the estimate). - What is billed.
GET /api/v1/billing/overview(billing.vieworbilling.manage) explains the organization's next invoice (detailed lines, usage of the period, projection, last statement, last month);GET /api/v1/domains/<id>/billing-overviewgives its summary per organization for a domain, andGET /api/v1/system/billing-overviewper domain for the platform.
Default team
An organization works without any setup: it gets a default team (is_default: true, named
"Équipe principale", or "Main team" when the language of its first user is English; the name can
be changed freely) when it is created (sign-up, OAuth sign-in, POST /api/v1/organizations) or,
for an older organization, when its first invitation is accepted. The numbers the organization
receives (POST /api/v1/domains/<id>/phone-numbers/<id>/assign) are put in that team, and a
member invited without a team whose profiles do not give members.manage (the former agent role) joins it.
An organization with several teams and none flagged as default keeps its numbers without team: a member
holding numbers.manage assigns them with
POST /api/v1/phone-numbers/<id>/assign.
Inbound webhooks
Providers deliver inbound SMS to /webhook/provider/<provider>/<secret>, the inbound URL of an
SMS provider account (one secret per account, shown with the account and
regenerable). The message is routed to the team
the destination number is assigned to in the phone number registry. Each team keeps its
own conversation thread per correspondent: a number reassigned to another team opens a new
thread.
Two older forms still exist: /webhook/sms/<team secret> and
/webhook/domain/<provider>/<domain secret>. The relative paths of the second form are the ones
GET /api/v1/domains/<id>/webhook and POST /api/v1/domains/<id>/webhook/regenerate return. The
provider webhooks from before the accounts (/api/v1/system/provider-webhooks, super admin, without a
screen) also give a URL of the current form and are still accepted.
These addresses are not subject to any rate limit: the secret of the URL authenticates the provider, and an inbound SMS is never refused by a counter.
Only the delivery webhook of the number provider (POST /webhook/numbers/<token>, set by the domain
administrator) has a limit: 120 per minute (see Rate limits).
When the WEBHOOK_MIRROR_URL variable is set on the server, every delivery is replayed in
the background to that URL (used to feed the development instance).
SMS provider accounts
An SMS provider account (sms_provider_accounts) holds the credentials of an account at an SMS provider
and its own inbound URL /webhook/provider/<provider>/<secret>. It belongs either to the
platform (/api/v1/system/sms-accounts, super admin: configured once and shared by the domains the super
admin allows, PUT /api/v1/domains/<id>/platform-sms-accounts) or to one domain
(/api/v1/domains/<id>/sms-accounts, domain admin: a reseller's own account).
The provider is chosen by the number: a number's provider_account_id
(PUT /api/v1/domains/<id>/phone-numbers/<id>). A number without an account uses the provider settings of its
domain (/api/v1/domains/<id>/sms-providers), as before the accounts existed: that legacy setting is no longer
offered by the interface, except for a number that still uses it. A domain admin never sees the
name nor the provider of a platform account: to them the account is only an id and the description the super
admin wrote, and their numbers show the provider platform. The super admin picks the domains of an account from
the account (PUT /api/v1/system/sms-accounts/<id>/domains) or from the domain
(PUT /api/v1/domains/<id>/platform-sms-accounts).
Numbers are added and removed in the inventory of their account (/api/v1/system/sms-accounts/<id>/numbers,
/api/v1/domains/<id>/sms-accounts/<id>/numbers); a number assigned to an organization cannot leave it (409).
A free number of a platform account changes domain with POST /api/v1/system/sms-accounts/<id>/numbers/move.
A domain adds its own accounts only when the super admin allows it (PUT /api/v1/domains/<id>/sms-account-settings).
The SMS log of a domain (GET /api/v1/domains/<id>/sms-logs) gives for each message the account of its number
(provider_account_id) and its provider_description (the description of a platform account, never its name) and
filters by account (account_id); the filter by provider type is for the super admin only.
Text-to-speech
POST /api/v1/voice/sounds/tts creates a sound from a text (text, 1000 characters at most, language fr, en or es,
optional name); GET /api/v1/voice/sounds/tts tells whether it is offered and in which languages. The sound counts like an
uploaded one (same limits) and keeps its text (tts). Synthetic voices: Piper; the French voice comes from the
SIWIS French Speech Synthesis Database (University of Edinburgh, CC-BY 4.0), the English one from LJ Speech (public
domain), the Spanish one from davefx (CC0).
Voice trunks
SIP trunks follow the same principle. A platform trunk (/api/v1/system/voice-trunks, super admin) is
configured once and shared by the allowed domains (PUT /api/v1/system/voice-trunks/<id>/domains or
PUT /api/v1/domains/<id>/platform-voice-trunks); a domain admin only sees its id and description
(platform_trunks of GET /api/v1/domains/<id>/voice-trunks). A domain adds its own trunks
(POST /api/v1/domains/<id>/voice-trunks) only with the same permission as its own SMS accounts
(own_accounts_allowed, else 403 own_accounts_not_allowed). A trunk has a direction: in and out, in only
(outbound_enabled: false) or out only (inbound_enabled: false), never neither (400); without
outbound_enabled, a registration trunk lets calls out and an IP trunk does not; a trunk that does not let calls
out may name outbound_trunk_id, another trunk of the same owner that does, through which its calls and
forwards leave (400 field: outbound_trunk_id otherwise). A number gets the voice with voice_trunk_id. The
voice of a platform number (bound to a platform SMS account or trunk) is set by the super admin
(PUT /api/v1/system/phone-numbers/<id>/voice): a domain admin who sends voice_trunk_id for such a number gets
403, they only assign it to an organization. For the numbers of its own accounts, the domain admin picks one of its
own trunks (PUT /api/v1/domains/<id>/phone-numbers/<id>). A number that changes domain keeps a platform trunk
allowed in its new domain, and the voice of a number without a trunk (static default platform trunk).
Inbound, a message is only delivered to a number the account may serve: a domain's account never delivers
to another domain's numbers (404), and a number bound to an account receives only through it. For a platform
account and a domain that is not allowed, the message is delivered and logged while
SMS_INBOUND_ENFORCE_PLATFORM_ACCOUNTS is off, and refused once it is on.
Integrations and outbound webhooks
An integration gives a third-party application (a CRM, a dispatch tool, ...) access to
the teams an organization chooses to expose, and pushes the events of those teams to it. It
is created under Settings → Integrations (permission integrations.manage, see the
guide) or with POST /api/v1/integrations and a JWT (the
/api/v1/integrations* routes refuse every API key), and consists of:
- a name (80 characters at most), shown as the sender of the messages the integration sends;
- the list of exposed teams (empty = every team of the organization, including those created later);
- an API key (
X-API-Key), shown once at creation and replaceable (POST /api/v1/integrations/<id>/regenerate-key: the old one stops working at once). It is a restricted key: it only sees the exposed teams, on every route (GET /api/v1/conversationswithoutteam_idincluded), with the rights of the User profile: read and send, no administration. Billing, organizations, voice and push notifications answer it 403; - an optional outbound webhook: a public https URL, a signing secret (
whsec_…, readable at any time, replaceable withPOST /api/v1/integrations/<id>/regenerate-secret) and the list of delivered events (all of them by default).
The API key is shown only once, right after creation: copy it before dismissing the box.
The key is held by a hidden service account: it appears neither among the members nor in the
per-agent metrics (its sends count in the team totals), and cannot log in. Disabling the
integration (enabled: false) stops both the key and the webhook without erasing anything.
Deleting it revokes the key and erases the service account and the delivery log: the messages it
sent lose the "via Mon CRM" mention and, without sender_name, show no sender at all. To stop an
integration and keep the history, disable it.
Calling the API with the key
Send the key in X-API-Key, from your server. X-Frontend-Host is optional: when present, it must
be the host of the organization's Telma app (app.telma.ca, or the one of your whitelabel site).
With the host of another domain there is no error but no team either: lists come back empty
(total: 0) and every route that targets a team answers 403.
The useful routes are in the SMS & conversations, Teams, Conversation notes and Uploads groups of the reference:
| Route | Use |
|---|---|
GET /api/v1/teams |
the exposed teams and their id |
GET /api/v1/teams/<team_id>/phone-numbers |
the numbers of a team |
GET /api/v1/conversations |
open and closed conversations, most recent first (team_id, status, replied, q, number, unread, skip, limit) |
GET /api/v1/conversations/<id> |
a conversation, its messages (most recent first) and its events; reading marks it as read |
POST /api/v1/sms/send |
send an SMS or an MMS |
POST /api/v1/upload |
upload an MMS attachment |
POST /api/v1/conversations/<id>/close, .../reopen |
close, reopen |
PATCH /api/v1/conversations/<id> |
name the contact of the conversation (contact_name, null to clear it) |
GET, POST /api/v1/conversations/<id>/notes, PUT, DELETE .../notes/<note_id> |
conversation notes |
GET /api/v1/media-proxy |
media of inbound MMS (the proxied_url of the events) |
Pagination. Lists accept skip (0 by default, a negative value counts as 0, 2147483647 at
most) and limit (50 by default, 100 at most: a larger value is brought back to 100, 0 or less to
1). A skip or limit that is not an integer, or a skip over 2147483647, gets 400
validation_error with field. GET /api/v1/conversations repeats skip and limit and gives in
total the number of conversations, GET /api/v1/teams likewise with the number of teams,
GET /api/v1/conversations/<id> the number of messages, GET .../notes the number of notes. Teams
and notes are all returned when limit is absent (GET /api/v1/teams then repeats
"limit": null).
Conversations. A conversation is open or closed: since 2026-10-14 closing is archiving (there is
no separate archived state any more; POST .../archive is still accepted and closes the
conversation, and the former archived ones became closed). A closed conversation carries the date of
its closing, closed_at, and a message from the contact reopens it. GET /api/v1/conversations only
accepts status=open or status=closed (empty = both); status=archived gets 400. The closed
conversations of a team, filed by day of closing, are also in GET /api/v1/teams/<team_id>/archives
(and .../archives/dates), which require messages.archive (Admin and Manager profiles), hence refused to a restricted key. replied=1 keeps only the conversations the contact wrote to at least once,
replied=0 those waiting for their first answer.
Sending. An integration key has no team of its own: POST /api/v1/sms/send requires team_id
or from_number (the number then names its team), otherwise 403. to is required, and so is
body unless there are attachments. sender_name (the person sending from your application) and
external_sender (their identifier on your side), 120 characters at most, give the message
sent_by_name = that person and sent_via = the integration name: Telma shows
"Julie Tremblay via Mon CRM" above the bubble. Without sender_name, the sender shown is the
integration. A refusal from the provider answers 500 send_failed; the message stays in the
conversation with status failed, and sending it again creates a second one.
A message sent from the CRM on behalf of Julie Tremblay (“Julie Tremblay via My CRM”) and a conversation note written through the same integration, in the thread and in the Notes panel.
Attachments (MMS). Upload the file first: POST /api/v1/upload as multipart/form-data,
field file, 10 MB at most, extension jpg, jpeg, png, gif, webp, mp4, mov, mp3, wav or m4a. The 201
answer gives url, content_type and filename. Pass url as is in media_urls (a list of
strings or of {"url": …} objects): any other URL is refused (400 validation_error), and the type
recorded is the uploaded file's. The file is served without authentication (the provider must be
able to download it), at an address that cannot be guessed.
Conversation notes. They are the team's comments, never sent to the contact (body of 4000
characters at most, when creating as when editing). A note sits in the timeline at its moment
(anchor_at) or right after the message it comments (message_id). With an integration key,
author_name carries the name of the person writing in the third-party application (otherwise the
integration name) and external_author their identifier on your side. The key only edits or
deletes the notes it wrote; check external_author on your side before letting a user change a
note.
Closings and reopenings. Reading a conversation also returns events: every closing or
reopening, in timeline order (type closed/reopened, cause agent = an agent or the
integration, inbound = message from the contact, outbound = message sent in an archived
conversation, before 2026-10-14 (a send in a closed conversation leaves it closed), actor_name, via, message_id of the inbound message that reopened it,
anchor_at). With an integration key, POST .../close and .../reopen accept actor_name (the
person acting in your application, 120 characters at most).
Example: curl quickstart
These commands are replayed as is by the Telma tests. The answers are real, shortened (…), with
made-up identifiers. A customer wrote to the number of the "Sales" team, exposed to the "Mon CRM"
integration.
export TEXTOS_API=https://api.textos.ca
export TEXTOS_KEY=txts_exemple0123456789abcdef # shown only once, when the integration is created
1. The exposed teams and their numbers
curl -s "$TEXTOS_API/api/v1/teams" -H "X-API-Key: $TEXTOS_KEY"
curl -s "$TEXTOS_API/api/v1/teams/6710a0000000000000000001/phone-numbers" -H "X-API-Key: $TEXTOS_KEY"
{"teams": [{"id": "6710a0000000000000000001", "name": "Sales", "organization_id": "6710a0000000000000000040",
"phone_number": "+15145550100", "timezone": "America/Montreal", …}],
"total": 1, "skip": 0, "limit": 50}
{"phone_numbers": [{"number": "+15145550100", "label": "Sales", "capabilities": ["SMS", "MMS"]}]}
2. The open conversations the customer wrote to, then reading one of them
curl -s "$TEXTOS_API/api/v1/conversations?status=open&replied=1" -H "X-API-Key: $TEXTOS_KEY"
curl -s "$TEXTOS_API/api/v1/conversations/6710a0000000000000000002" -H "X-API-Key: $TEXTOS_KEY"
{"conversations": [{"id": "6710a0000000000000000002", "team_id": "6710a0000000000000000001", "team_name": "Sales",
"phone_number": "+15145550123", "team_phone_number": "+15145550100", "contact_name": null,
"status": "open", "unread_count": 1, "last_message_at": "2026-10-09T18:43:39.429000", …}],
"total": 1, "skip": 0, "limit": 50}
{"conversation": {"id": "6710a0000000000000000002", "status": "open", "unread_count": 0, …},
"messages": [{"id": "6710a0000000000000000003", "direction": "inbound", "from_number": "15145550123",
"to_number": "15145550100", "body": "Hello, has my container been delivered?", "status": "received",
"media_urls": [], "sent_by": null, "sent_by_name": null, "created_at": "2026-10-09T18:43:39.429000", …}],
"total": 1, "events": [], "capabilities": ["SMS", "MMS"]}
Reading marks the conversation as read (unread_count back to 0). An inbound message keeps the
provider's format ("from_number": "15145550123"): identify the contact with
conversation.phone_number.
3. Sending an SMS, by team_id or by from_number
curl -s "$TEXTOS_API/api/v1/sms/send" -H "X-API-Key: $TEXTOS_KEY" -H "Content-Type: application/json" \
-d '{"team_id": "6710a0000000000000000001", "to": "+15145550123", "body": "Yes, delivered this morning at 9."}'
curl -s "$TEXTOS_API/api/v1/sms/send" -H "X-API-Key: $TEXTOS_KEY" -H "Content-Type: application/json" \
-d '{"from_number": "+15145550100", "to": "+15145550123", "body": "Thank you for your business!"}'
201 answer (the second one has the same shape):
{"message": {"id": "6710a0000000000000000010", "conversation_id": "6710a0000000000000000002", "direction": "outbound",
"from_number": "+15145550100", "to_number": "+15145550123", "body": "Yes, delivered this morning at 9.",
"status": "sent", "sent_by": "6710a0000000000000000031", "sent_by_name": "Mon CRM", "sent_via": null,
"external_sender": null, "media_urls": [], "created_at": "2026-10-09T18:43:39.879130", …},
"conversation": {"id": "6710a0000000000000000002", "status": "open", …}}
sent_by is the integration's service account.
4. Sending in the name of a person of your application
curl -s "$TEXTOS_API/api/v1/sms/send" -H "X-API-Key: $TEXTOS_KEY" -H "Content-Type: application/json" \
-d '{"team_id": "6710a0000000000000000001", "to": "+15145550123", "body": "I will call you back in 5 minutes.", "sender_name": "Julie Tremblay", "external_sender": "user-42"}'
{"message": {"id": "6710a0000000000000000012", "body": "I will call you back in 5 minutes.", "status": "sent",
"sent_by_name": "Julie Tremblay", "sent_via": "Mon CRM", "external_sender": "user-42", …},
"conversation": {…}}
5. Sending an MMS: upload, then send the URL you got
curl -s "$TEXTOS_API/api/v1/upload" -H "X-API-Key: $TEXTOS_KEY" -F "file=@photo.jpg"
curl -s "$TEXTOS_API/api/v1/sms/send" -H "X-API-Key: $TEXTOS_KEY" -H "Content-Type: application/json" \
-d '{"team_id": "6710a0000000000000000001", "to": "+15145550123", "body": "Delivery photo", "media_urls": ["https://api.textos.ca/api/v1/uploads/9f1c2e3d4b5a69788766554433221100.jpg"]}'
{"url": "https://api.textos.ca/api/v1/uploads/9f1c2e3d4b5a69788766554433221100.jpg", "content_type": "image/jpeg",
"filename": "9f1c2e3d4b5a69788766554433221100.jpg"}
{"message": {"id": "6710a0000000000000000013", "body": "Delivery photo", "status": "sent",
"media_urls": [{"url": "https://api.textos.ca/api/v1/uploads/9f1c2e3d4b5a69788766554433221100.jpg",
"content_type": "image/jpeg"}], …},
"conversation": {…}}
6. Adding a note (never sent to the contact)
curl -s "$TEXTOS_API/api/v1/conversations/6710a0000000000000000002/notes" -H "X-API-Key: $TEXTOS_KEY" -H "Content-Type: application/json" \
-d '{"body": "Customer called back, delivery confirmed.", "author_name": "Julie Tremblay", "external_author": "user-42"}'
201 answer, in full:
{"note": {"id": "6710a0000000000000000020", "conversation_id": "6710a0000000000000000002",
"team_id": "6710a0000000000000000001", "message_id": null, "anchor_at": "2026-10-09T18:43:39.930821",
"body": "Customer called back, delivery confirmed.", "author_id": "6710a0000000000000000031",
"author_name": "Julie Tremblay", "via": "Mon CRM", "external_author": "user-42",
"created_at": "2026-10-09T18:43:39.930821", "updated_at": "2026-10-09T18:43:39.930821"}}
7. Closing, then reopening, in the name of the person
curl -s "$TEXTOS_API/api/v1/conversations/6710a0000000000000000002/close" -H "X-API-Key: $TEXTOS_KEY" -H "Content-Type: application/json" \
-d '{"actor_name": "Julie Tremblay"}'
curl -s "$TEXTOS_API/api/v1/conversations/6710a0000000000000000002/reopen" -H "X-API-Key: $TEXTOS_KEY" -H "Content-Type: application/json" \
-d '{"actor_name": "Julie Tremblay"}'
{"conversation": {"id": "6710a0000000000000000002", "status": "closed", …}, "message": "Conversation fermée"}
{"conversation": {"id": "6710a0000000000000000002", "status": "open", …}}
message is in French unless the request sends Accept-Language: en ("Conversation closed"). The
next reading of the conversation gives these two changes in events:
"events": [{"id": "6710a0000000000000000050", "type": "closed", "cause": "agent", "previous_status": "open",
"actor_name": "Julie Tremblay", "via": "Mon CRM", "message_id": null,
"anchor_at": "2026-10-09T18:43:39.939000", …},
{"id": "6710a0000000000000000051", "type": "reopened", "cause": "agent", "previous_status": "closed",
"actor_name": "Julie Tremblay", "via": "Mon CRM", "message_id": null,
"anchor_at": "2026-10-09T18:43:39.948000", …}]
The same calls with X-Frontend-Host: app.telma.ca (the organization's host) give the same
answers; with the host of another domain, the list comes back empty
({"conversations": [], "total": 0, "skip": 0, "limit": 50}) and the send answers 403.
Delivered events
| Event | When | Content |
|---|---|---|
message.received |
an inbound SMS/MMS is stored in an exposed team | conversation, message |
message.sent |
an outbound message is sent: by an agent, by the integration, by the website widget or automatically (welcome message, out-of-hours reply, delivered right after the inbound message they answer). A refused send (500 send_failed) is not delivered |
conversation, message |
message.status |
a provider that sends delivery receipts reports a status (pending, sent, delivered, failed). The platform's current providers send none: for their numbers, this event never comes and the status stays the one of the send |
message, conversation |
conversation.updated |
a conversation is read (when it had unread messages), closed or reopened, whether by an agent, by the integration, by a message from the contact (or, before 2026-10-14, by a send in an archived conversation); or its contact is renamed (PATCH /api/v1/conversations/<id>, by an agent or by the integration; the same name again delivers nothing). Deleting a conversation is not reported |
conversation, change (read, closed, reopened, renamed; archived is no longer sent since 2026-10-14: archiving closes), cause (agent, inbound, outbound; absent for read and renamed), message_id (reopening by a message from the contact), contact_name (renamed only: the new name, or null when it is cleared) |
conversation.note |
a conversation note is added, edited or deleted | note, change (created, updated, deleted), conversation_id |
Each delivery is a JSON POST to the webhook URL. The envelope carries id (the delivery
identifier), event, created_at, organization_id and team (id, name), then the fields of
the event. An MMS received, as delivered:
{
"event": "message.received",
"created_at": "2026-10-09T18:43:39.431526Z",
"organization_id": "6710a0000000000000000040",
"team": {"id": "6710a0000000000000000001", "name": "Sales"},
"conversation": {
"id": "6710a0000000000000000002",
"team_id": "6710a0000000000000000001",
"phone_number": "+15145550123",
"contact_name": null,
"team_phone_number": "+15145550100",
"last_message_at": "2026-10-09T18:43:39.429000",
"last_inbound_at": "2026-10-09T18:43:39.429000",
"unread_count": 2,
"status": "open",
"created_at": "2026-10-09T18:43:39.402000",
"archived_at": null,
"closed_at": null,
"capabilities": ["SMS", "MMS"],
"last_message": {"id": "6710a0000000000000000004", "body": "Here is the photo", "direction": "inbound", "status": "received", "created_at": "2026-10-09T18:43:39.429512", "has_media": true}
},
"message": {
"id": "6710a0000000000000000004",
"conversation_id": "6710a0000000000000000002",
"team_id": "6710a0000000000000000001",
"direction": "inbound",
"from_number": "15145550123",
"to_number": "15145550100",
"body": "Here is the photo",
"status": "received",
"provider": null,
"provider_message_id": "msg-in-2",
"sent_by": null,
"sent_by_name": null,
"sent_via": null,
"external_sender": null,
"queued": false,
"rejected": false,
"credit_hold": false,
"media_urls": [
{
"url": "https://mms.provider.example/media/7f3e2a.jpg",
"content_type": "image/jpeg",
"proxied_url": "https://api.textos.ca/api/v1/media-proxy?url=https%3A%2F%2Fmms.provider.example%2Fmedia%2F7f3e2a.jpg&content_type=image%2Fjpeg"
}
],
"created_at": "2026-10-09T18:43:39.429512",
"delivered_at": null
},
"id": "6711b0000000000000000002"
}
A message received outside the team's business hours carries queued: true if the team queues such
messages, or rejected: true if it rejects them: the message is kept and shown, but the team gets no
push notification. Both are false otherwise.
conversation and message have the format of the API (GET /api/v1/conversations/<id>), with
three additions or differences. conversation.last_message ({id, body, direction, status, created_at,
has_media}) is the preview of the last message of the conversation, the one it counts at the time of the
delivery, as in the API:
- a message sent by an integration also carries
integration_id, so your application recognises its own sends; - every media carries
proxied_url. For a file uploaded to Telma (POST /api/v1/upload), it is that file's own address; for a media kept by the provider, it is the public Telma relay (GET /api/v1/media-proxy): it only relays the media of messages (the URL must be exactly theurlof a message's media, otherwise 404not_found), only accepts the SMS providers' host names (over http or https, ports 80 and 443, without credentials or IP addresses, and only when they point to public addresses), 5 MB at most, within 90 s at most, without following redirects; it converts AMR audio to MP3 and 3GPP video to MP4, displays images, audio, video, PDF and plain text with their type, and serves any other file as a download (application/octet-stream, namedmedia.<extension>after its type, for examplemedia.vcffor a contact card); a media fetched is kept up to 10 minutes: the same URL asked again meanwhile is served without being fetched again, converted or not, and acceptsRangerequests (a video played piece by piece); a refused URL gets 400validation_error, a media the provider does not return 502fetch_failed, and when too many media are already being fetched or converted (a request waits its turn 15 s at most, behind 100 others at most), the relay answers 503busy: retry a few seconds later; - in
message.status,sent_viaisnullandsent_by_nameonly keeps thesender_namegiven at sending time (nullfor an agent's message).
message.provider is null (the provider is only shown to the platform), and provider_message_id is
the identifier the provider gave the message. What a number can send is read in capabilities
(["SMS"] or ["SMS", "MMS"]) of GET /api/v1/teams/<id>/phone-numbers and in messaging_capabilities
of the numbers; their provider is null for an organization.
message.from_number and message.to_number of an inbound message keep the provider's format
(often without +): identify the contact with conversation.phone_number and the team number with
conversation.team_phone_number, both E.164.
All dates are UTC. The envelope's created_at ends with Z; the dates of conversation and
message (as in the API) have no suffix: add Z before parsing them (in JavaScript,
new Date("2026-10-09T18:43:39.429000") would read it as local time).
Contact renamed (conversation.updated, "change": "renamed", abridged): contact_name gives the new name
(null when it is cleared), the same as conversation.contact_name; no cause. The name belongs to
the conversation: another conversation with the same number (another team number, another team)
keeps its own.
{
"event": "conversation.updated",
"created_at": "2026-10-14T15:02:11.204518Z",
"organization_id": "6710a0000000000000000040",
"team": {"id": "6710a0000000000000000001", "name": "Sales"},
"conversation": {"id": "6710a0000000000000000002", "phone_number": "+15145550123", "contact_name": "Marie Tremblay", "status": "open", "…": "…"},
"change": "renamed",
"contact_name": "Marie Tremblay",
"id": "6711b000000000000000000c"
}
Request headers:
| Header | Content |
|---|---|
X-Textos-Event |
the event name |
X-Textos-Delivery |
the delivery identifier (same as id, repeated on every retry) |
X-Textos-Timestamp |
the sending time, in Unix seconds |
X-Textos-Signature |
sha256= followed by the hex HMAC-SHA256 of "<timestamp>.<raw body>" with the webhook secret |
User-Agent |
Textos-Webhooks/1.0 |
Example: real payloads
Bodies received while running the quickstart (made-up identifiers). A send by the integration in
the name of a person (message.sent):
Full message.sent payload
{
"event": "message.sent",
"created_at": "2026-10-09T18:43:39.882741Z",
"organization_id": "6710a0000000000000000040",
"team": {"id": "6710a0000000000000000001", "name": "Sales"},
"conversation": {
"id": "6710a0000000000000000002",
"team_id": "6710a0000000000000000001",
"phone_number": "+15145550123",
"contact_name": null,
"team_phone_number": "+15145550100",
"last_message_at": "2026-10-09T18:43:39.881000",
"last_inbound_at": "2026-10-09T18:43:39.429000",
"unread_count": 0,
"status": "open",
"created_at": "2026-10-09T18:43:39.402000",
"archived_at": null,
"closed_at": null,
"capabilities": ["SMS", "MMS"],
"last_message": {"id": "6710a0000000000000000012", "body": "I will call you back in 5 minutes.", "direction": "outbound", "status": "sent", "created_at": "2026-10-09T18:43:39.881523", "has_media": false}
},
"message": {
"id": "6710a0000000000000000012",
"conversation_id": "6710a0000000000000000002",
"team_id": "6710a0000000000000000001",
"direction": "outbound",
"from_number": "+15145550100",
"to_number": "+15145550123",
"body": "I will call you back in 5 minutes.",
"status": "sent",
"provider": null,
"provider_message_id": "msg-0003",
"sent_by": "6710a0000000000000000031",
"sent_by_name": "Julie Tremblay",
"sent_via": "Mon CRM",
"external_sender": "user-42",
"queued": false,
"rejected": false,
"credit_hold": false,
"media_urls": [],
"created_at": "2026-10-09T18:43:39.881523",
"delivered_at": null,
"integration_id": "6710a0000000000000000030"
},
"id": "6711b0000000000000000005"
}
A conversation closed, then reopened by a new message from the contact (conversation.updated):
cause is inbound and message_id names that message, delivered next as message.received. A
closing (including by the former POST .../archive) or reopening by an agent or by the integration has the same shape with
"cause": "agent"; read has no cause.
Full conversation.updated payload
{
"event": "conversation.updated",
"created_at": "2026-10-09T18:43:40.112093Z",
"organization_id": "6710a0000000000000000040",
"team": {"id": "6710a0000000000000000001", "name": "Sales"},
"conversation": {
"id": "6710a0000000000000000002",
"team_id": "6710a0000000000000000001",
"phone_number": "+15145550123",
"contact_name": null,
"team_phone_number": "+15145550100",
"last_message_at": "2026-10-09T18:43:40.111000",
"last_inbound_at": "2026-10-09T18:43:40.111000",
"unread_count": 1,
"status": "open",
"created_at": "2026-10-09T18:43:39.402000",
"archived_at": null,
"closed_at": null,
"capabilities": ["SMS", "MMS"],
"last_message": {"id": "6710a0000000000000000005", "body": "One more question", "direction": "inbound", "status": "received", "created_at": "2026-10-09T18:43:40.111482", "has_media": false}
},
"change": "reopened",
"cause": "inbound",
"message_id": "6710a0000000000000000005",
"id": "6711b000000000000000000b"
}
A note added (conversation.note): no conversation object, only conversation_id. For
"change": "deleted", note is the note as it was before the deletion.
Full conversation.note payload
{
"event": "conversation.note",
"created_at": "2026-10-09T18:43:39.931077Z",
"organization_id": "6710a0000000000000000040",
"team": {"id": "6710a0000000000000000001", "name": "Sales"},
"note": {
"id": "6710a0000000000000000020",
"conversation_id": "6710a0000000000000000002",
"team_id": "6710a0000000000000000001",
"message_id": null,
"anchor_at": "2026-10-09T18:43:39.930821",
"body": "Customer called back, delivery confirmed.",
"author_id": "6710a0000000000000000031",
"author_name": "Julie Tremblay",
"via": "Mon CRM",
"external_author": "user-42",
"created_at": "2026-10-09T18:43:39.930821",
"updated_at": "2026-10-09T18:43:39.930821"
},
"change": "created",
"conversation_id": "6710a0000000000000000002",
"id": "6711b0000000000000000007"
}
ping (Test webhook button, POST /api/v1/integrations/<id>/test), with
X-Textos-Delivery: test:
{
"id": "test",
"event": "ping",
"created_at": "2026-10-09T18:43:40.310448Z",
"organization_id": "6710a0000000000000000040",
"integration": {"id": "6710a0000000000000000030", "name": "Mon CRM"}
}
Verifying the signature
Compute the HMAC over the body as received (the bytes, before any JSON decoding: re-encoded JSON
does not give the same signature), compare in constant time and reject timestamps that are too old
(five minutes here). These functions return False on a missing or malformed header, without
raising:
import hashlib
import hmac
import time
def textos_signature_is_valid(secret, headers, raw_body, tolerance=300):
# headers: request.headers (Flask, Django, FastAPI: case-insensitive names)
# raw_body: the bytes received (Flask: request.get_data())
timestamp = headers.get("X-Textos-Timestamp") or ""
if not (timestamp.isascii() and timestamp.isdigit()):
return False
if abs(time.time() - int(timestamp)) > tolerance:
return False
expected = "sha256=" + hmac.new(
secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
given = headers.get("X-Textos-Signature") or ""
return hmac.compare_digest(expected.encode(), given.encode())
const crypto = require("crypto");
// headers: req.headers (Node, Express: lower-case names)
// rawBody: the Buffer received (Express: express.raw({ type: "application/json" }))
function textosSignatureIsValid(secret, headers, rawBody, tolerance = 300) {
const timestamp = String(headers["x-textos-timestamp"] || "");
if (!/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > tolerance) return false;
const expected = Buffer.from("sha256=" + crypto.createHmac("sha256", secret)
.update(`${timestamp}.`).update(rawBody).digest("hex"));
const given = Buffer.from(String(headers["x-textos-signature"] || ""));
return expected.length === given.length && crypto.timingSafeEqual(expected, given);
}
Getting the raw body, by framework:
| Framework | Raw body | Headers |
|---|---|---|
| Flask | request.get_data() |
request.headers |
| Django | request.body |
request.headers |
| Express | express.raw({ type: "application/json" }) on this route, then req.body (a global app.use(express.json()) gives an object and makes the check fail) |
req.headers |
| Fetch (Next.js App Router, Workers, Deno, Bun) | Buffer.from(await request.arrayBuffer()) |
Object.fromEntries(request.headers) |
While the secret is being changed, your receiver can accept both the old and the new one (the delivery is valid if either verifies it).
Example: complete webhook receiver
Both receivers below do the same thing: they verify the signature over the raw body, answer 204
at once, ignore a retry of a delivery already accepted (same X-Textos-Delivery), process the event
in the background and ignore the sends made by their own integration (message.integration_id). The
ping gets 204 without going through deduplication, since its X-Textos-Delivery is always
test. Unknown event types are ignored: new types may be added. They are tested for real against
signed Telma deliveries.
TEXTOS_WEBHOOK_SECRET is the webhook secret (whsec_…, Settings → Integrations) and
TEXTOS_INTEGRATION_ID the integration identifier (integration.id of the answer to its creation,
or of the ping). The seen set and the queue live in memory: they only hold for a single
process. In production, record the delivery identifiers in your database (unique constraint, seven
days are enough) and hand the processing to your job queue.
Python (Flask) — pip install flask, then
TEXTOS_WEBHOOK_SECRET=whsec_… TEXTOS_INTEGRATION_ID=6710a0000000000000000030 flask --app textos_webhook run --port 8000:
textos_webhook.py
# textos_webhook.py
import hashlib
import hmac
import json
import logging
import os
import queue
import threading
import time
from flask import Flask, abort, request
SECRET = os.environ["TEXTOS_WEBHOOK_SECRET"]
INTEGRATION_ID = os.environ.get("TEXTOS_INTEGRATION_ID") # to recognise your own sends
app = Flask(__name__)
log = logging.getLogger("textos")
jobs = queue.Queue()
seen = set() # deliveries accepted; in production: a table with a unique constraint
seen_lock = threading.Lock()
def textos_signature_is_valid(secret, headers, raw_body, tolerance=300):
timestamp = headers.get("X-Textos-Timestamp") or ""
if not (timestamp.isascii() and timestamp.isdigit()):
return False
if abs(time.time() - int(timestamp)) > tolerance:
return False
expected = "sha256=" + hmac.new(
secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
given = headers.get("X-Textos-Signature") or ""
return hmac.compare_digest(expected.encode(), given.encode())
@app.post("/textos/webhook")
def textos_webhook():
raw = request.get_data() # the bytes received, before any JSON decoding
if not textos_signature_is_valid(SECRET, request.headers, raw):
abort(401)
event = json.loads(raw)
if event["event"] == "ping": # "Test" button: X-Textos-Delivery is always "test"
return "", 204
delivery_id = request.headers["X-Textos-Delivery"]
with seen_lock:
if delivery_id in seen: # retry of a delivery already accepted
return "", 204
seen.add(delivery_id)
jobs.put(event)
return "", 204 # answer quickly: Telma does not wait for the answer more than 10 s
def handle(event):
kind = event["event"]
conversation = event.get("conversation") or {}
message = event.get("message") or {}
if kind == "message.received":
# the contact: conversation.phone_number (E.164); message.from_number keeps the provider's format
media = message.get("media_urls") or [] # attachments: media[i]["proxied_url"]
log.info("SMS from %s: %s (%d attachment(s))", conversation["phone_number"], message["body"], len(media))
elif kind == "message.sent":
if message.get("integration_id") == INTEGRATION_ID:
return # sent by your application: already known
log.info("Reply from %s to %s: %s", message["sent_by_name"], conversation["phone_number"], message["body"])
elif kind == "message.status":
log.info("Message %s: %s", message["id"], message["status"])
elif kind == "conversation.updated":
log.info("Conversation %s: %s (%s)", conversation["id"], event["change"], event.get("cause", "-"))
elif kind == "conversation.note":
note = event["note"]
log.info("Note %s %s: %s", note["id"], event["change"], note["body"])
# other events: ignored
def worker():
while True:
event = jobs.get()
try:
handle(event)
except Exception:
log.exception("Event %s not processed", event.get("id"))
logging.basicConfig(level=logging.INFO)
threading.Thread(target=worker, daemon=True).start()
Node (Express 4 or 5) — npm install express, then
TEXTOS_WEBHOOK_SECRET=whsec_… TEXTOS_INTEGRATION_ID=6710a0000000000000000030 node textos-webhook.js:
textos-webhook.js
// textos-webhook.js
const crypto = require("crypto");
const express = require("express");
const SECRET = process.env.TEXTOS_WEBHOOK_SECRET;
const INTEGRATION_ID = process.env.TEXTOS_INTEGRATION_ID; // to recognise your own sends
const seen = new Set(); // deliveries accepted; in production: a table with a unique constraint
function textosSignatureIsValid(secret, headers, rawBody, tolerance = 300) {
const timestamp = String(headers["x-textos-timestamp"] || "");
if (!/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > tolerance) return false;
const expected = Buffer.from("sha256=" + crypto.createHmac("sha256", secret)
.update(`${timestamp}.`).update(rawBody).digest("hex"));
const given = Buffer.from(String(headers["x-textos-signature"] || ""));
return expected.length === given.length && crypto.timingSafeEqual(expected, given);
}
const app = express();
// Raw body on this route: a global app.use(express.json()) would make the check fail
app.post("/textos/webhook", express.raw({ type: "application/json" }), (req, res) => {
if (!Buffer.isBuffer(req.body) || !textosSignatureIsValid(SECRET, req.headers, req.body)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
if (event.event === "ping") return res.sendStatus(204); // "Test" button: X-Textos-Delivery is always "test"
const deliveryId = req.headers["x-textos-delivery"];
if (seen.has(deliveryId)) return res.sendStatus(204); // retry of a delivery already accepted
seen.add(deliveryId);
res.sendStatus(204); // answer quickly: Telma does not wait for the answer more than 10 s
setImmediate(() => {
try {
handle(event);
} catch (err) {
console.error(`Event ${event.id} not processed`, err);
}
});
});
function handle(event) {
const conversation = event.conversation || {};
const message = event.message || {};
switch (event.event) {
case "message.received": {
// the contact: conversation.phone_number (E.164); message.from_number keeps the provider's format
const media = message.media_urls || []; // attachments: media[i].proxied_url
console.log(`SMS from ${conversation.phone_number}: ${message.body} (${media.length} attachment(s))`);
break;
}
case "message.sent":
if (message.integration_id === INTEGRATION_ID) return; // sent by your application: already known
console.log(`Reply from ${message.sent_by_name} to ${conversation.phone_number}: ${message.body}`);
break;
case "message.status":
console.log(`Message ${message.id}: ${message.status}`);
break;
case "conversation.updated":
console.log(`Conversation ${conversation.id}: ${event.change} (${event.cause || "-"})`);
break;
case "conversation.note":
console.log(`Note ${event.note.id} ${event.change}: ${event.note.body}`);
break;
default: // other events: ignored
}
}
app.listen(Number(process.env.PORT) || 8000);
To try it without Telma, send yourself a signed delivery (date +%s gives the timestamp, the
body is signed as is):
SECRET=whsec_…; TS=$(date +%s); BODY='{"id":"try-1","event":"conversation.updated","change":"read","conversation":{"id":"6710a0000000000000000002","status":"open"}}'
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -i http://localhost:8000/textos/webhook -H "Content-Type: application/json" \
-H "X-Textos-Event: conversation.updated" -H "X-Textos-Delivery: try-1" \
-H "X-Textos-Timestamp: $TS" -H "X-Textos-Signature: sha256=$SIG" -d "$BODY"
# → HTTP/1.1 204; the same call with another secret or a modified body → 401
Telma itself only calls a public https address (see below): to receive real deliveries, publish the receiver behind https.
Delivery, retries and pause
The webhook URL must be https on port 443 (the default https port), public, 500 characters at
most, without credentials (user:password@) nor spaces. Its host name is resolved, and every
address it gets must be public: private, loopback, link-local, reserved, multicast, unspecified or
non-global addresses (100.64.0.0/10 included) are refused, IPv4 as well as IPv6 (an IPv4 carried in
an IPv6 included), and so is a name that does not resolve. The public address of the server that
hosts Telma is not refused: an application hosted on that same server receives its webhooks. The
platform's operator may also exclude networks (the server's WEBHOOK_DENY_NETWORKS variable, empty
by default). Every refusal gives
400 {"error": "validation_error", "message": "webhook_url must be a public https URL"}, whatever
the cause. The URL is checked when it is saved with a new value, and again before each call: if it
no longer passes, nothing is sent and the call fails (address not allowed, or connection error
when the name no longer resolves). The URL already saved, sent again unchanged (the interface
sends the whole form), is not checked again: an integration can be renamed or disabled even when
the name of its receiver no longer resolves. "" or null removes the webhook.
The receiver must answer 2xx: Telma connects within 5 s, waits 10 s at most for the answer and
gives up on the call after 15 s in all. A redirect (3xx) is never followed and counts as a failure.
The status line and headers of the response are read up to 8 KB a line and 32 KB in all: beyond
that, the call fails (connection error). The response body is ignored: Telma reads 1 KB of it at
most, without keeping or showing it. A failure is only recorded as HTTP <code>, timeout,
connection error, redirect not followed, address not allowed or not sent (below).
Telma makes at most 16 webhook calls at once, at most 3 of them for one organization; the waiting
organizations take the places that free up in turn, and the calls of one organization go in order.
A call waiting for its turn is never held against the receiver: X-Textos-Timestamp and the
signature are computed when the call is made, after the wait. A delivery waits 10 minutes at most
for its turn; when it did not get it, or when too many calls are already waiting (2000 in all, 100
for one organization), nothing is sent: the delivery is marked not sent, which counts neither as
an attempt, nor among the failures in a row, nor towards the pause, and it stays in progress
(completed_at is null). Telma takes it up again by itself one to two minutes later (and again
as long as it does not get its turn), with the attempts it has left, to the URL and with the secret
of that moment: the oldest first, 20 a minute at most for one integration, and for one hour after
the event. After that, or when the integration no longer takes that event (integration disabled,
webhook removed or paused, event or team unticked), the delivery ends with the error not sent,
without any call.
Each delivery makes at most three attempts (the first one, then after 5 s and after 30 s), with the
same X-Textos-Delivery, so the receiver must be idempotent on that identifier. Deliveries are
asynchronous and may arrive out of order: created_at and the fields of the object are
authoritative.
After 25 failed deliveries in a row the webhook is paused (the last error is shown in the
interface and in webhook.last_error of GET /api/v1/integrations/<id>); the API key keeps
working. It resumes when the integration is updated with webhook_paused: false, or when its URL
changes (the failure count starts again from zero). The events that happen during the pause are
never delivered: catch up through the API (GET /api/v1/conversations).
The webhook: masked signing secret, health, result of “Test webhook” and the recent deliveries log.
POST /api/v1/integrations/<id>/test sends a signed ping event, without retry, even when the
integration is disabled or its webhook paused, and answers for example
{"success": true, "status_code": 204, "error": null} or
{"success": false, "status_code": 500, "error": "HTTP 500"}. The ping has a reduced shape: id
and X-Textos-Delivery are always test (do not deduplicate it), integration (id, name)
replaces team, and it has neither conversation nor message. It is not listed among the
deliveries and does not count in the webhook health. The receiver must answer 2xx to ping and
ignore the events it does not know. The ping waits 5 s at most for its turn (otherwise
{"success": false, "status_code": null, "error": "not sent"}, nothing is sent), and the route
accepts 10 tests per minute per organization, all its integrations together (429 rate_limited
beyond).
GET /api/v1/integrations/<id>/deliveries lists the most recent deliveries with their payload: 50
by default, limit from 1 to 200 (a limit that is not an integer gets 400), without pagination.
Deliveries are kept seven days.
Security
- Keep the key on the server side. It reads the conversations of the exposed teams and sends SMS billed to the organization: never put it in a browser, a mobile app or a code repository; read it from an environment variable or a secret manager. A web page or a mobile app goes through your own server.
- Least privilege. Only expose the teams the application needs, and create one integration per application: each one has its own key, secret and delivery log.
- What the key cannot do. It only has the rights of the User profile on the exposed teams: it touches no other team,
does not list a team's closed conversations by day (
.../archives), reads neither the billing, nor the members, nor the organization settings, and can create neither keys nor integrations (403). The closed conversations of an exposed team stay listed withstatus=closedand readable by their id. - https is mandatory. Events contain phone numbers and the text of the messages: the webhook URL must be a public https address.
- Verify the signature over the raw body of every delivery, with a constant-time comparison, and refuse timestamps older than five minutes (Verifying the signature). Answer 401 without processing anything when it does not match.
- Idempotency. Record the
X-Textos-Deliveryalready processed (seven days are enough): a retry replays the same delivery. - Rotation. Regenerate key (
POST /api/v1/integrations/<id>/regenerate-key) replaces the key: the old one gets 401 at once. Regenerate secret (POST /api/v1/integrations/<id>/regenerate-secret) replaces the secret: the next deliveries are signed with the new one, which your receiver must already accept. Do it as soon as a key or a secret may have leaked. - Media. Uploaded files and
proxied_urladdresses are public, at an address that cannot be guessed: do not republish them.
Example: errors to handle
Real answers of the API to an integration key, sent with Accept-Language: en (without it,
message is in French; test error and the HTTP status, not message):
| Situation | HTTP | Body |
|---|---|---|
| Unknown key, replaced with Regenerate key, integration disabled or deleted | 401 | {"error": "unauthorized", "message": "Invalid API key"} |
team_id or from_number of a team that is not exposed, a send with neither team_id nor from_number, or the X-Frontend-Host of another domain |
403 | {"error": "forbidden", "message": "Access denied"} |
| Route closed to restricted keys (billing, organization, archives, integrations…) | 403 | {"error": "forbidden", "message": "API key not allowed on this route"} |
from_number that is not a number of the team_id team |
400 | {"error": "number_not_in_team", "message": "The number +15145550200 is not (or no longer) assigned to this team"} |
media_urls that does not come from POST /api/v1/upload |
400 | {"error": "validation_error", "message": "media_urls must point to uploaded attachments"} |
Neither body nor media_urls |
400 | {"error": "validation_error", "message": "Message body required"} |
| A body that is not a JSON object | 400 | {"error": "validation_error", "message": "The request body must be a JSON object"} |
status=archived (or anything but open, closed) in GET /api/v1/conversations |
400 | {"error": "validation_error", "message": "status must be open or closed (archived conversations: GET /api/v1/teams/<team_id>/archives)", "field": "status"} |
| Not enough credit (nothing is sent nor recorded) | 402 | {"error": "insufficient_balance", "message": "billing.insufficient_balance", "estimated_cost": 0.04, "current_balance": 0.0} |
Refused by the provider (message and error_code come from it) |
500 | {"error": "send_failed", "message": "Destination refused by carrier", "error_code": "21610", "message_id": "6710a0000000000000000015"} |
More than 600 POST /api/v1/sms/send in the hour for the key, refusals included |
429 | {"error": "rate_limited", "message": "Rate limit exceeded (600 per 1 hour), retry in 1312 s", "retry_after": 1312} |
On a 500 send_failed, the message already exists in the conversation with status failed
(message_id): a new attempt creates a second message, do not retry without a decision. A 429 is
returned before any processing: nothing was sent, retry after Retry-After seconds (see
Rate limits, with a Python and a Node client that do it).
Request headers
| Header | Role |
|---|---|
Authorization: Bearer <token> |
the access token (JWT), see JWT token |
X-API-Key |
the API key, instead of the token, see API key |
Accept-Language |
fr or en: the language of message in the answers (French by default) |
X-Frontend-Host |
the whitelabel domain aimed at (otherwise the Host header), see Multi-domain |
X-Admin-Organization |
super_admin and domain_admin: the organization being administered, see Administration context |
X-Organization-Fallback: none |
GET /api/v1/organizations/current: do not fall back to another organization of the domain when the current one does not fit; the answer is then 404 not_found |
Idempotency-Key |
POST /api/v1/billing/recharge: 8 to 128 characters (or idempotency_key in the body); the same key never charges twice and returns the first answer, with replayed: true |
X-MFA-Token |
the mfa_token of a sign-in that must first add a second factor, on the routes that add a factor (see Sign-in security) |
X-Client-App: ios or android |
the mobile app, which sends no Origin: the passkeys are then those of the site of X-Frontend-Host |
X-Request-ID |
the request identifier (64 characters at most): when absent, the server makes one up; every answer carries it, to quote in a support request |
Errors
All errors are JSON, with at least error (a stable code, to test) and message (a readable text,
translated according to Accept-Language, which may change):
The providers' inbound webhooks (/webhook/sms, /webhook/domain and
/webhook/provider) are the exception: they answer {"error": "<text>"}, a free text without message
(for example 404 {"error": "Invalid webhook token"}), and that text must not be tested.
Some add fields: field (the parameter at fault in a 400), retry_after (429), estimated_cost
and current_balance (402), error_code and message_id (500 send_failed).
| HTTP status | error |
Meaning |
|---|---|---|
| 400 | validation_error |
invalid or missing data |
| 400 | configuration_error |
the team has no number, or its SMS provider is not configured or enabled |
| 400 | number_not_in_team |
from_number is not (or no longer) a number of the team |
| 401 | unauthorized |
authentication required, invalid or expired token, API key unknown, revoked or of a disabled integration |
| 402 | insufficient_balance |
not enough credit to send; nothing is sent |
| 402 | plan_limit |
the organization's plan does not allow one more user or team (limit, plan, upgrade, current; see Plans) |
| 402 | auto_recharge_required |
choosing a plan requires automatic recharge by card |
| 402 | account_suspended |
the balance stayed negative past the grace period: sending, system SMS and calls are refused until the balance is back to zero or more (balance_state, release_at, balance in the answer) |
| 403 | forbidden |
insufficient rights (permission, team or domain); API key not allowed on this route for a route closed to the key |
| 403 | recording_skip_not_allowed |
the organization does not allow skipping the imposed recording of the next call (see Voice) |
| 403 | outbound_suspended |
the organization's outbound calls are suspended (call code refused; see Voice) |
| 403 | number_disabled |
the number that would send is switched off (paused, inactive or without SMS) |
| 403 | voice_not_enabled, capability_required |
the organization lacks the voxos capability, or the one capability names (see Capabilities) |
| 403 | impersonation_forbidden |
operation refused to a session opened by an administrator (see Session opened by an administrator) |
| 404 | not_found |
unknown resource, or no organization in this domain |
| 405 | method_not_allowed |
HTTP method not supported by this route (the Allow header gives the accepted methods) |
| 409 | mfa_required |
removing the last second factor of an account that must have one (the other codes of the second factor are in Sign-in security) |
| 409 | profile_in_use, last_admin |
profile still held by members; change that would leave no member with members.admin |
| 409 | conflict |
duplicate (already a member of the organization, domain admin already invited...); an organization invitation already pending gets 400 validation_error |
| 413 | request_entity_too_large |
body over 32 MB (POST /api/v1/upload accepts 10 MB at most and refuses more with 400 validation_error, not 413) |
| 429 | rate_limited |
rate limit reached (see Rate limits) |
| 500 | send_failed |
the SMS provider refused or failed |
| 500 | internal_error |
unexpected server error |
| 404 | not_found |
GET /api/v1/media-proxy: no message carries this media (pass url as is, from media_urls[].url) |
| 502 | fetch_failed |
GET /api/v1/media-proxy: the provider did not return the media in time, or it is over 5 MB |
| 503 | busy |
GET /api/v1/media-proxy: too many media being fetched or converted; retry a few seconds later |
The billing routes (/api/v1/billing/*) follow the same shape: no_organization (400, the user has
no current organization), validation_error (400, with field: amount, threshold, invalid ISO 8601
date), configuration_error (400, no payment method), payment_declined (402, the provider refused: reason is card_declined, insufficient_funds,
card_expired, cvv_declined or card_restricted; the response keeps success: false), domain_not_found (404) and conflict (409, the default
pricing plan cannot be deleted). Before this harmonization, error held an English text there and
message was absent.
The account routes (/auth/me/password, /auth/me/email, /auth/me/phone, /auth/verifications) add their own codes (invalid_password, invalid_code, code_expired, too_many_attempts, email_in_use, invalid_phone, sms_unavailable...), listed in Verification by code.
Other HTTP errors have the same shape, with the lower-case name of the error as error
(bad_request, unsupported_media_type, ...), and keep their HTTP headers (Allow of a 405,
Content-Range of a 416 on a file...). A few administration routes have their own codes,
listed in the reference.
Pagination
Lists accept skip (offset, 0 by default; a negative value counts as 0) and limit (page size, 50
by default, brought back into 1 to 100). A value that is not an integer gets 400 validation_error
with field (skip or limit), never a 500. The response carries the list under the name of the
resource, plus:
total is the number of items matching the filters of the request, whatever the page. This is the
shape of GET /conversations, GET /voice/calls, GET /voice/voicemails, GET /voice/diagnostics,
GET /phone-numbers, GET /organizations, GET /billing/usage, GET /billing/transactions,
GET /billing/invoices and most other lists: the reference gives total, skip and
limit in the return of every route that has them. The balance is read with
GET /api/v1/billing/balance.
Real time (Socket.IO)
The Socket.IO server is on the same host as the API (path /socket.io). The client gives its access
token at connection (io(url, { auth: { token } })); without a valid token the connection is
refused with unauthorized (unknown or revoked token) or token_expired (the client refreshes its
token and reconnects).
Rooms. At connection, the socket joins, without asking:
user_<id>: the user's personal room (events that only concern them);team_<id>: one room per team the user can access (asGET /conversations: every team of the current organization withteams.all, otherwise their own). Withauth: { token, member_only: true }(or?member_only=1), only the teams of the member view of the mobile app, judged with the profiles of each membership:teams.all(Admin, Manager) every team, otherwise only the team the member is placed in (nothing without one); a platform permission adds nothing, so adomain_admindoes not follow every organization of their domain (same rule asmember_onlyofGET /conversations).
Administration mode. An account with platform_admin or domain_admin can open the connection with
io(url, { auth: { token, admin_organization: "<organization_id>" } }) (or ?admin_organization=):
same rules as the X-Admin-Organization header (without these permissions: connection refused forbidden; unknown
organization: not_found; domain admin outside their domains: forbidden). The socket then joins
user_<id>, admin_org_<organization_id> and the room of each team of that organization only
(not those of its own memberships). The room admin_org_<id> gets the organization's
balance_update and account_state, and unread_update with the totals of all its teams. The
organization is fixed for the life of the socket: join_team and mark_read outside it are
ignored. The connection is journaled (audit_logs, type admin_realtime_connected) and the rooms
are taken away if the account loses its platform permission.
A client can still emit join_team {team_id} (team room, refused without access), leave_team
{team_id} (leaves the team room only) and mark_read {conversation_id}. A team that appears after the
connection (new team, user placed in a team) is joined with join_team or at reconnection.
| Event (server to client) | Room | Payload |
|---|---|---|
new_message |
team | {message, conversation} |
message_status |
team | {message_id, status} |
conversation_update |
team | {conversation, change: read, closed, reopened, archived or renamed, type, cause?, message_id?, contact_name?}: type = the same value as change; renamed (contact renamed or name cleared) carries contact_name, the new name or null |
conversation_note |
team | {note, change, conversation_id} |
user_joined, user_left |
team | {user_id} |
balance_update |
user_<id> of every member of the organization |
{balance, currency, state}: new balance, CAD, state = ok, low (when the organization set a low-balance alert: balance at or under its threshold, never low when the alert is off; otherwise under the auto-recharge threshold when it is on, otherwise under $10) or empty (zero or less); sent on every credit or debit |
unread_update |
user_<id> of the users who can access the team |
{conversations, messages}: conversations with unread messages and number of unread messages, over every accessible team of the user's current organization, closed conversations included; sent on every inbound message and every read |
call_request |
user_<id> of the user who made the request |
{id, status, call_id?}: status change of a call request (pending, used, connected, expired, cancelled, failed); requests are created with POST /api/v1/voice/call-requests |
account_state |
user_<id> of every member of the organization, and admin_org_<id> in administration mode |
{state, previous_state, balance, currency, negative_since, grace_ends_at, suspended_at, limbo_at, release_at, released_at}: state of the prepaid account (UTC dates in ISO 8601 with Z); sent on every transition and every extension of the delay |
low_balance |
user_<id> of every member of the organization |
{balance, threshold, currency}: the balance has just gone under the threshold of the low-balance alert |
auto_recharge_failed |
user_<id> of every member of the organization |
{reason, amount, balance, currency}: the automatic recharge failed |
voicemail_new |
user_<id> of every member of the organization who may listen to this message (same rule as GET /api/v1/voice/voicemails) |
{voicemail}: a voicemail message has just been left in a box of the organization; read GET /api/v1/voice/voicemails again, which only gives the messages the user may hear |
call_update |
user_<id> of every member of the organization who sees this call (same rule as GET /api/v1/voice/calls) |
{call}: incoming or missed call |
dnd_update |
user_<id> of the user |
{dnd: {active, until, set_at}}: their do not disturb has just changed (PUT /auth/me/dnd, from another device); same object as GET /auth/me/dnd |
port_update |
user_<id> of every member of the organization |
{id, state, numbers: [{number, state}]}: an inbound port request of the organization changed state |
"Every member of the organization" means the active users whose current organization it is.
balance_update and unread_update carry the current state: the client replaces its value, it does
not add.
Rate limits
| Requests | Limit | Counted per |
|---|---|---|
| any route without a limit of its own | 500 per hour and 2000 per day, for each route | API key, user or IP address (see below) |
POST /api/v1/sms/send |
100 per hour | user, or API key other than an integration's |
POST /api/v1/sms/send with an integration's key |
600 per hour | key |
POST /auth/login |
30 failures per 15 minutes; if the client's address is known, also 10 per 15 minutes per e-mail and address, and 100 per 15 minutes per address | e-mail address sent; client address |
POST /auth/signup |
10 per hour; 20 per hour if the client's address is known | e-mail address sent; client address |
PUT /auth/me/password, PUT /auth/me/email, PUT /auth/me/phone |
10 per hour | user |
POST /auth/verifications |
30 per hour, on top of the code sending limits (see Verification by code) | user |
POST /auth/verifications/<id>/confirm |
60 per hour | user |
POST /auth/register, POST /auth/reset-password |
10 per hour | token sent (invitation, reset) |
DELETE /auth/me (deleting one's own account) |
5 per hour | user |
PATCH /auth/me when the body changes the password (password, deprecated) |
10 per hour | user |
POST /api/v1/keys (creating a key) |
10 per hour | user |
POST /api/v1/organizations/<id>/members/<user_id>/impersonate |
20 per hour | user |
POST /api/v1/domains/<id>/number-market/order (buying numbers for the domain's bank) |
30 per hour | user |
POST /api/v1/voice/diagnostics (diagnostics report of a call) |
120 per hour | user |
POST /api/v1/billing/payment-methods, POST /api/v1/billing/payment-methods/session, POST /api/v1/billing/payment-methods/tokenize, POST /api/v1/billing/payment-methods/tokenize/verify (cards) |
30 per hour | user or key |
POST /webhook/numbers/<token> (delivery by the number provider) |
120 per minute | IP address (see below) |
POST /auth/forgot-password |
5 per hour | e-mail address sent |
POST /widget/<key>/start (website widget) |
10 per minute and 60 per hour; 3 per hour and 10 per day to the same number | widget; destination number, across all widgets |
POST /api/v1/integrations/<id>/test |
10 per minute | the integration's organization, all its integrations together |
POST /auth/mfa/verify (second step of the sign-in) |
10 failures per 15 minutes | account of the mfa_token, whatever the token |
POST /auth/webauthn/login (sign-in with a passkey) |
30 failures per 15 minutes; when the client's address is known, also 30 failures per 15 minutes per address | passkey presented (its id); client's address |
POST /auth/webauthn/login/options |
60 per 15 minutes when the client's address is known (else no limit) | client's address |
POST /auth/mfa/webauthn/options |
60 per 15 minutes | account of the mfa_token |
POST /auth/mfa/totp/confirm, POST /auth/mfa/totp/disable, POST /auth/mfa/backup-codes, POST /auth/webauthn/register, POST /auth/webauthn/credentials/<id>/remove |
20 failures per 15 minutes | account (session or X-MFA-Token) |
POST /auth/oauth/google/callback, POST /auth/oauth/microsoft/callback, POST /auth/oauth/apple/callback |
30 exchanges per minute, on top of the default limits | provider, for the whole platform: only the code exchanges sent to the provider count |
A route with a limit of its own is not subject to the default limits. Those are counted route by
route: reading 500 conversations does not touch the counter of GET /api/v1/teams.
A request is counted as soon as it arrives, before any check: every request counts, including the refused ones (400, 401, 403...), with five exceptions:
OPTIONSrequests (browsers' CORS preflight) never count;POST /auth/logincounts only its failures (401): a successful sign-in or a malformed request (400) consumes nothing, and the counter is checked before the password;POST /api/v1/integrations/<id>/testis only counted once the authentication and the permission are checked: a request without a valid session (401), from a user withoutintegrations.manage(403) or with anX-API-Keyheader (403) does not count. A call on an unknown integration (404) or on one the user does not administer (403) counts for that user alone, not for the integration's organization;- on
POST /widget/<key>/start, only the welcome texts handed to the provider count (a refused request, an invalid number, the bot trap, a text already sent to the same number within the minute or a text Telma could not hand to the provider, for lack of credit or of an enabled provider, does not). Each text is counted right before it is handed to the provider: simultaneous requests do not exceed these limits. The per-number counter is the one of the number actually texted, however it is written: "514 555 9999" and "+1 514 555 9999" are one and the same number. "+" followed by 10 digits depends on the provider of the widget's number: with a provider that reads North American numbers, "+5145559999" is read as 1-514-555-9999 (the number it texts); with a provider that sends the international number as given, "+47 923 45 678" stays a Norwegian number; - the common cap of the OAuth callbacks (
POST /auth/oauth/google/callback,POST /auth/oauth/microsoft/callback,POST /auth/oauth/apple/callback) works the same way: a callback refused before the exchange (invalid body, provider not enabled for the domain) does not touch it, and each exchange is counted right before it is sent to the provider. The default limits of these routes, for their part, count every request as soon as it arrives.
Not subject to any limit: the providers' inbound webhooks (/webhook/sms, /webhook/domain,
/webhook/provider and /webhook/billing, but not /webhook/numbers/<token>, limited above), the
uploaded files (GET /api/v1/uploads/<file>) and the media relay (GET /api/v1/media-proxy), which
browsers load without a key, and the website widget's script (GET /widget/<key>.js), which every
visitor of the sites that embed it loads.
The public routes of the login page are not limited either: GET /auth/oauth/providers,
GET /auth/oauth/google, GET /auth/oauth/microsoft and GET /auth/oauth/apple (which only build the sign-in
address), POST /auth/oauth/apple/return (which only checks the signature of the state and sends the browser on
to its page),
GET /auth/signup/config and GET /api/v1/domains/current.
Each request is counted, in this order:
- on the anonymous authentication routes, per target, whatever the other headers (
X-API-Key,Authorization): the e-mail address sent forPOST /auth/login,POST /auth/forgot-passwordandPOST /auth/signup, the token sent forPOST /auth/registerandPOST /auth/reset-password, the authorization code sent forPOST /auth/oauth/google/callback,POST /auth/oauth/microsoft/callbackandPOST /auth/oauth/apple/callback(per IP address when the body has none; their exchanges with the provider also have the common cap of the table), and the token of the address forGET /api/v1/invitations/verify/<token>; - with a valid
X-API-Key(existing and not revoked), per key: every user of your application who goes through the same key shares its counters; - with a valid JWT, per user (for
POST /auth/refreshandPOST /auth/logout, the user of the refresh token); a token that Telma signed and that expired less than an hour ago is counted separately, for its user: it therefore gets401, which makes the client refresh its session, and never a429caused by anonymous traffic, without ever using up the counters of that user's valid requests (a token expired longer ago counts as an anonymous request); - otherwise, per client IP address.
A made-up or revoked key creates no counter: the request is counted as without a key (steps 1, 3
or 4 above). On a route that requires authentication, it gets 401, or 403 on the routes closed to
every key as soon as an X-API-Key header is present (/api/v1/integrations* and those that would
give another way to sign in, see API key). The counters of the website widget (per
widget, per destination number) ignore X-API-Key, Authorization and the visitor's address.
IP address. Behind the TLS relay of the production host (sslh) every request arrives from
127.0.0.1: Telma does not know the real address of its clients. A loopback, private or local
address is never taken for a client's (utils/client_ip.py): the limits "per address" (the failure
budgets of POST /auth/login, sign-up) then do not apply, instead of being one counter for the
whole platform. The other "per address" counters (anonymous routes without a target) stay shared by
every anonymous client, route by route: without a key or JWT, your requests share the default limits
with everyone else's, and a 429 may come from other people's traffic. Authenticate your calls (API
key or JWT) to get counters of your own. Infrastructure limit: the real address appears with no
code change as soon as sslh passes it on (transparent mode, or the PROXY protocol between sslh and
nginx with nginx's realip module: set_real_ip_from 127.0.0.1; real_ip_header proxy_protocol;).
If nginx rather puts a single address in a header (proxy_set_header X-Real-IP $remote_addr;), the
name of that header goes in the environment variable TRUSTED_CLIENT_IP_HEADER; Telma never reads
a header the client can send as it likes.
The answers of limited routes carry these headers, exposed to browsers through CORS:
| Header | Content |
|---|---|
X-RateLimit-Limit |
the current ceiling: the one of the shortest period, or the one reached |
X-RateLimit-Remaining |
the requests left in that period |
X-RateLimit-Reset |
the end of that period, in Unix seconds |
Retry-After |
on a 429 only: the number of seconds to wait |
Beyond the ceiling, the answer is a JSON 429; retry_after (an integer, at least 1) and
Retry-After have the same value:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 1312
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1791574536
{"error": "rate_limited", "message": "Rate limit exceeded (600 per 1 hour), retry in 1312 s", "retry_after": 1312}
A refused send is not queued
A 429 is returned before any processing: the SMS is neither sent, nor recorded, nor held at
Telma. Your application must retry it after Retry-After seconds; an automatic SMS (reminder,
confirmation...) it does not retry is lost. If your sends get close to the ceiling, spread
them out or queue them on your side.
Examples of a client that retries after a 429, honouring Retry-After (tested against the real 429
of the API); beyond max_wait seconds of waiting, they return the error so you can put the send
back in your own queue:
import os
import time
import requests
TEXTOS_API = os.environ.get("TEXTOS_API", "https://api.textos.ca")
TEXTOS_KEY = os.environ["TEXTOS_KEY"]
class TextosError(Exception):
def __init__(self, status, body):
super().__init__(f"{status} {body.get('error')}: {body.get('message')}")
self.status, self.body = status, body
def textos_send(payload, max_wait=900):
"""POST /api/v1/sms/send: returns the created message; retries after a 429, raises TextosError otherwise."""
waited = 0
while True:
response = requests.post(f"{TEXTOS_API}/api/v1/sms/send", json=payload,
headers={"X-API-Key": TEXTOS_KEY}, timeout=30)
try:
body = response.json()
except ValueError: # answer of an intermediary (502, 504...): not JSON
body = {"error": f"http_{response.status_code}", "message": response.text[:200]}
if response.status_code == 201:
return body["message"]
if response.status_code == 429: # nothing was sent: it can be retried
delay = int(response.headers.get("Retry-After", "60"))
if waited + delay <= max_wait:
time.sleep(delay)
waited += delay
continue
# 500 send_failed: body["message_id"] is already recorded as failed, do not resend blindly
raise TextosError(response.status_code, body)
const TEXTOS_API = process.env.TEXTOS_API || "https://api.textos.ca";
const TEXTOS_KEY = process.env.TEXTOS_KEY;
class TextosError extends Error {
constructor(status, body) {
super(`${status} ${body.error}: ${body.message}`);
this.status = status;
this.body = body;
}
}
// POST /api/v1/sms/send: returns the created message; retries after a 429, throws TextosError otherwise
async function textosSend(payload, maxWait = 900) {
let waited = 0;
for (;;) {
const response = await fetch(`${TEXTOS_API}/api/v1/sms/send`, {
method: "POST",
headers: { "X-API-Key": TEXTOS_KEY, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
// the answer of an intermediary (502, 504...) is not JSON
const body = await response.json().catch(() => ({ error: `http_${response.status}`, message: "" }));
if (response.status === 201) return body.message;
if (response.status === 429) { // nothing was sent: it can be retried
const delay = Number(response.headers.get("retry-after")) || 60;
if (waited + delay <= maxWait) {
await new Promise((resolve) => setTimeout(resolve, delay * 1000));
waited += delay;
continue;
}
}
// 500 send_failed: body.message_id is already recorded as failed, do not resend blindly
throw new TextosError(response.status, body);
}
}
Monitoring
GET /health (no authentication, never rate limited) answers 200 {"status": "ok", "mongo": true} when the
API and its database answer, 503 {"status": "degraded", "mongo": false} otherwise: it is the probe of the
monitoring tools, not a route for your clients.
GET /api/uploads/<file> is the old address of the uploaded files: it redirects (301) to
GET /api/v1/uploads/<file>.