Skip to content

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:

Authorization: Bearer <access_token>

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:

  1. POST /auth/verifications {channel: "email"|"sms", target, purpose?, country?} sends a 6-digit code (valid 10 minutes, stored hashed) and answers 202 {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 setting settings.verification.sms_from); with no usable number: 503 sms_unavailable. country (ISO code of the country) gives the country of an sms target; it is deduced from the area code for a North American number.
  2. POST /auth/verifications/<id>/confirm {code} answers {verification: {id, verified: true}, verified_token, expires_in}. The verified_token is single use and valid 10 minutes.
  3. PUT /auth/me/email {email, password, verified_token} or PUT /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 sets phone.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:

X-API-Key: <key>

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 a platform_admin or domain_admin account 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_id at 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/invitations and POST /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 contains password, whoever the user (the key's creator included); your own password is only changed with PUT /auth/me/password anyway (403 elsewhere, even with a session);
  • a body that gives the admin role (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>/admins and DELETE /api/v1/domains/<id>/admins/<user_id>;
  • changing the SMTP and OAuth settings (PUT /api/v1/domains/<id>/settings/smtp and its test POST .../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 contains hostnames or name: the domain's hostnames give the address of the links of its sign-in emails. display_name and capabilities can 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:

E-mail 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/login counts only the failures (401): 30 per 15 minutes and per e-mail address, then 429 rate_limited with Retry-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}: the state is made and signed by the server (10 minutes, single use, bound to the provider, the domain and the redirect_uri) and the URL carries a PKCE challenge (S256). The client keeps the state (sessionStorage), refuses a return whose state differs, and sends it with the code to POST /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 (Google sub, Microsoft id, Apple sub). 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 same 400 oauth_error with 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's POST /auth/oauth/apple/return, which sends it on (303) to that page with code, state and, the first time only, user (the name); the page sends code, state and user to POST /auth/oauth/apple/callback. What Apple says about the person is the id_token of the exchange, checked with Apple's public keys (signature, issuer, client ID, expiry and the nonce bound to the state). 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}. The mfa_token (5 minutes, single use, dead when the password changes, never a session token) goes with a code or a passkey assertion to POST /auth/mfa/verify (method = totp, backup_code or webauthn; 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 claim mfa: true.
  • Sign-in with a passkey. POST /auth/webauthn/login/options then POST /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 (the Origin of the request, which must be a host of a known domain). The mobile app sends no Origin but X-Client-App: ios or android with X-Frontend-Host: the passkey is then the one of that host's site (the site declares the app in /.well-known/apple-app-site-association and /.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 gives mfa_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 with mfa_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_setup carries the deadline; after it, the password only gives an enrollment mfa_token (mfa_enrollment_required, 15 minutes), to put in the X-MFA-Token header 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 handle mfa_required and mfa_enrollment_required.
  • Headers. Every API answer carries X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer; /auth/* answers also carry Cache-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.x and *.textos.ca are honored in development and testing only (FLASK_ENV).
  • Journal. The writes made with X-Admin-Organization are 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:

X-Frontend-Host: other.telma.ca

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 former super_admin), domain_admin (the domains of domain_ids: all their organizations), support. They are given with PUT /api/v1/users/<id>/platform-permissions {permissions, domain_ids?} (platform_admin only; 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) and builtin: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 with members.manage or members.admin, write with members.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 with PUT /api/v1/organizations/current/members/<user_id>/profiles (members.admin; 409 last_admin when nobody would be left with members.admin, 402 plan_limit beyond the users of the plan) and an invitation can carry some (profile_ids of POST /api/v1/invitations). Changing the permissions of a profile is judged the same way for everyone who holds it: 402 plan_limit when members would start using the messages beyond the plan, 409 last_admin when an organization would have nobody left with members.admin, and nothing is changed. For a domain profile, the refusal names the organizations concerned (organizations: {id, name, error, limit, plan, upgrade, current}).
  • metrics.all opens the metrics of every team; metrics.team alone, those of the team one is placed in (another team: 403). The list of members (GET /organizations/current/members, members of GET /organizations/<id>) requires members.admin.
  • Acting on a member (reading them, changing their team, role or profiles, removing them, their API keys, their statistics): members.admin in the organization, or members.manage for a member of one's team who has neither members.manage nor members.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, field role or profile_ids); with members.manage alone, never members.manage nor members.admin. Widgets and integrations: integrations.manage; a team's inbound secret: teams.provider.
  • GET /auth/me gives platform_permissions, organization_permissions (current organization) and profile_ids; GET /api/v1/organizations/my gives permissions for 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

X-Admin-Organization: <organization_id>

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.

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/notifications gives and changes what the account receives: email.updates, survey, purchase, login (an e-mail at every sign-in, off by default), low_balance and its threshold low_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 through should_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; a null value 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 with 409 balance_negative while 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 with 403 last_admin for 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_use tells 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/trial gives the state, POST /billing/trial/activate {country, type?, area_code?, city?} gives the organization a number of the domain pool for trial.duration_days days (10 by default). It needs a verified phone (one phone number opens one trial per domain). Four e-mails to the members holding billing.manage (halfway, 2 days, 1 day, end); at the end the trial numbers go back to the pool. Reaching trial.minimum_credit of balance converts the trial into a normal account. GET /organizations/current/status sums up the state of the account (active, suspended or trial).

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 (pending in 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.manage are warned at 80%. Internal calls are never billed; international calls are billed at the domain's rate. The answer gives usage (the period's messages and minutes), seats and next_statement.
  • Limits. Beyond max_users (members holding messages.read or messages.send, at invitation or when profiles change) or max_teams (when a team is created): 402 plan_limit with limit (users or teams), plan, upgrade (the next plan, or null) and current (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_lines plus lines_per_full_user per full user, with a margin burst_lines above (by default as many as included, at least 2); the channel limit of the organization follows (source: subscription in GET /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 (line extra_line, also in the estimate).
  • What is billed. GET /api/v1/billing/overview (billing.view or billing.manage) explains the organization's next invoice (detailed lines, usage of the period, projection, last statement, last month); GET /api/v1/domains/<id>/billing-overview gives its summary per organization for a domain, and GET /api/v1/system/billing-overview per 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/conversations without team_id included), 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 with POST /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 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. 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 the url of a message's media, otherwise 404 not_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, named media.<extension> after its type, for example media.vcf for 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 accepts Range requests (a video played piece by piece); a refused URL gets 400 validation_error, a media the provider does not return 502 fetch_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 503 busy: retry a few seconds later;
  • in message.status, sent_via is null and sent_by_name only keeps the sender_name given at sending time (null for 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. 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 with status=closed and 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-Delivery already 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_url addresses 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):

{
  "error": "code",
  "message": "Human-readable message"
}

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:

{ "transactions": [], "total": 132, "skip": 50, "limit": 50 }

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 (as GET /conversations: every team of the current organization with teams.all, otherwise their own). With auth: { 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 a domain_admin does not follow every organization of their domain (same rule as member_only of GET /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:

  • OPTIONS requests (browsers' CORS preflight) never count;
  • POST /auth/login counts 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>/test is only counted once the authentication and the permission are checked: a request without a valid session (401), from a user without integrations.manage (403) or with an X-API-Key header (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:

  1. on the anonymous authentication routes, per target, whatever the other headers (X-API-Key, Authorization): the e-mail address sent for POST /auth/login, POST /auth/forgot-password and POST /auth/signup, the token sent for POST /auth/register and POST /auth/reset-password, the authorization code sent for POST /auth/oauth/google/callback, POST /auth/oauth/microsoft/callback and POST /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 for GET /api/v1/invitations/verify/<token>;
  2. 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;
  3. with a valid JWT, per user (for POST /auth/refresh and POST /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 gets 401, which makes the client refresh its session, and never a 429 caused 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);
  4. 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>.