Documentation de l'API
L'API REST de Telma est servie par https://api.textos.ca. Cette page décrit les concepts
(authentification, multi-domaine, rôles, erreurs). La liste complète des opérations, avec
leurs paramètres et leurs droits d'accès, est dans la référence de l'API,
générée à partir du code. La même information est disponible en OpenAPI sur /docs
(Swagger UI) et /static/swagger.yaml.
Les préfixes d'URL :
| Préfixe | Contenu |
|---|---|
/auth |
connexion, inscription, profil, OAuth |
/api/v1 |
tout le reste (conversations, équipes, organisations, domaines, facturation, ...) |
/webhook |
URL appelées par les fournisseurs SMS (les webhooks sortants sont décrits plus bas) |
/docs |
Swagger UI |
Authentification
Jeton JWT
POST /auth/login retourne un jeton d'accès et un jeton de rafraîchissement.
Le jeton d'accès s'envoie dans l'en-tête Authorization :
Quand il expire, POST /auth/refresh (avec le jeton de rafraîchissement dans le même
en-tête) retourne un nouveau jeton d'accès et un nouveau jeton de rafraîchissement,
qui remplace l'ancien (celui-ci reste accepté 60 secondes). POST /auth/logout, avec le
jeton de rafraîchissement, met fin à la session : le jeton de rafraîchissement et les jetons d'accès
encore valides de cette session sont refusés aussitôt. Un changement de mot de passe ou la
désactivation du compte invalide immédiatement tous les jetons émis auparavant.
Les jetons d'une même connexion forment une famille (revendication fam, conservée par la
rotation). Un jeton de rafraîchissement déjà remplacé qui est présenté de nouveau après ses 60
secondes de grâce est une copie (jeton volé ou rejoué) : toute la famille est révoquée, le jeton
le plus récent et les jetons d'accès compris, et l'usager doit se reconnecter. Les autres sessions
du même compte ne sont pas touchées. Un client ne doit donc jamais rejouer un ancien jeton de
rafraîchissement : il garde le dernier reçu.
Durée de la session et option remember
Le jeton d'accès dure 1 heure. Le jeton de rafraîchissement dure 30 jours et est renouvelé à
chaque POST /auth/refresh. POST /auth/login accepte remember (booléen, true par défaut) :
avec "remember": false, le jeton de rafraîchissement ne dure que 12 heures (réglable par
JWT_REFRESH_SHORT_HOURS sur le serveur), toujours renouvelé à chaque rafraîchissement : la
session s'arrête donc après 12 heures sans rafraîchissement. Le choix est conservé par la
rotation du jeton et par les nouveaux jetons de PUT /auth/me/password et
PUT /auth/me/email. Sans remember, rien ne change par rapport à avant (30 jours).
Se connecter sans connaître le domaine (any_domain)
Un client qui ne connaît pas le domaine en marque blanche du compte (l'app mobile, quand la
personne n'a pas saisi d'autre domaine) envoie "any_domain": true à POST /auth/login. Une
adresse courriel n'a qu'un compte sur toute la plateforme ; une fois le mot de passe accepté, la
réponse porte aussi domain: {id, name, host}, le domaine où travaille le compte : celui de
X-Frontend-Host si le compte y travaille, sinon le seul domaine où il travaille (s'il y en a
plusieurs, celui de son organisation courante). Le client envoie ensuite host comme
X-Frontend-Host pour le reste de la session. Un échec de connexion reçoit exactement la même
réponse que sans l'option (même 401, mêmes limites, même délai) : elle ne dit rien de
l'existence d'un compte. L'option est ignorée sur une requête qui porte X-API-Key.
Profil de l'utilisateur
GET /auth/me (et toute réponse qui contient un utilisateur : connexion, inscription, OAuth,
/api/v1/users) renvoie :
| Champ | Sens |
|---|---|
name |
nom affiché ; toujours égal à « prénom nom » quand ces deux champs sont modifiés |
first_name, last_name |
prénom et nom (déduits de name pour un compte plus ancien) |
phone |
null, ou {country, number, verified_at} : number en E.164 (+15145550123), verified_at nul tant que le numéro n'a pas été prouvé par SMS |
timezone |
nom IANA (America/Toronto), null tant qu'il n'est pas choisi |
language |
fr ou en |
permissions |
anciens noms de permissions (view_team_conversations, send_sms…) que donnent ses permissions de plateforme ou ses profils dans l'organisation courante, puis celles accordées au compte (support) ; les permissions des profils sont dans organization_permissions (voir Rôles et permissions) |
role |
agent, manager, admin, domain_admin ou super_admin : une copie déduite des profils et des permissions de plateforme, gardée pour les anciens clients ; elle ne décide de rien |
platform_permissions, organization_permissions, profile_ids |
GET /auth/me seulement : permissions de plateforme du compte, permissions dans l'organisation courante (toutes pour un admin de la plateforme ou de son domaine) et profils de son adhésion |
mfa |
GET /auth/me seulement : {enabled, methods, totp, passkeys, backup_codes_left, required, reason, deadline, enforced}, l'état du second facteur du compte |
PATCH /auth/me modifie name, first_name, last_name, language, timezone, phone
(non vérifié) et default_organization_id. Un usager ne peut ni s'attribuer de permissions
(403 forbidden) ni changer ses profils ou son rôle. Les permissions accordées (support) se posent par
PUT /api/v1/users/<id> (permissions), réservé aux super_admin et domain_admin. Un
numéro de téléphone doit appartenir à un pays accepté par le domaine : Canada et États-Unis par
défaut, d'autres pays (format international +... et country obligatoires) si le paramètre de
domaine settings.verification.phone_countries les liste. Un numéro invalide donne 400 invalid_phone.
Session ouverte par un administrateur
Un membre au profil Admin de l'organisation (toutes les permissions), un admin de son domaine ou un super admin peut ouvrir une session
en tant qu'un membre (« Se connecter en tant que »), sans son mot de passe :
POST /api/v1/organizations/<org_id>/members/<user_id>/impersonate, avec une session (jamais une clé
API, jamais depuis une session elle-même ouverte ainsi ; 20 par heure). Toutes les organisations du
membre doivent être administrées par l'appelant (un admin de domaine seulement par un super admin,
jamais un super admin, jamais soi-même : sinon 403 forbidden ou 403 member_of_other_organizations).
La réponse a la forme de POST /auth/login : une session du membre, courte (12 heures sans
rafraîchissement, comme remember: false), dont l'usager porte impersonated_by {id, name, email}
(l'administrateur), aussi dans GET /auth/me. Elle ne compte pas comme une connexion du membre, prend
fin si le compte de l'administrateur est désactivé, est journalisée (audit_logs,
impersonation_started) et ne peut ni changer le mot de passe, le courriel ou le téléphone du compte
(ni demander un code de vérification), ni le supprimer, ni créer une clé API : 403
impersonation_forbidden.
Mot de passe
PUT /auth/me/password {current_password, new_password} remplace l'ancien
PATCH /auth/me {password} (déprécié, il exige maintenant current_password lui aussi). La
règle : 8 à 128 caractères (72 octets UTF-8 au plus), au moins 2 sortes parmi minuscules,
majuscules, chiffres et autres caractères, pas un mot de passe très courant, ni l'adresse
courriel ni sa partie locale, et différent du mot de passe actuel (400 validation_error,
field: new_password). Un current_password incorrect donne 401 invalid_password. Toutes les
autres sessions prennent fin (token_version) ; la route renvoie de nouveaux access_token
et refresh_token qui gardent la session courante ouverte : le client doit les stocker à la place
des anciens. 10 essais par heure et par utilisateur.
POST /auth/forgot-password {email} répond toujours 200 avec le même message et envoie le lien
(valide 24 heures) après la réponse, par le SMTP du domaine du compte : la durée de la réponse ne
dit pas si l'adresse a un compte. Une nouvelle demande ne tue pas le lien déjà reçu : jusqu'à 5
liens sont valides ensemble, le plus ancien sort au sixième. POST /auth/reset-password
{token, password} suit la même règle de mot de passe que PUT /auth/me/password (400
validation_error, field: password), prend le lien avant de changer le mot de passe (deux
requêtes simultanées ne réussissent pas toutes les deux), met fin à toutes les sessions et annule
les autres liens du compte. Les jetons ne sont stockés que sous forme de condensat SHA-256.
Vérification par code, courriel et téléphone
Changer le courriel (clé de connexion) ou le téléphone exige la preuve que l'usager contrôle la nouvelle adresse ou le nouveau numéro :
POST /auth/verifications{channel: "email"|"sms", target, purpose?, country?}envoie un code à 6 chiffres (valide 10 minutes, stocké haché) et répond202{verification: {id, channel, purpose, target_masked, expires_in, resend_after}}. Le courriel part par le serveur SMTP du domaine de l'usager, le SMS par le fournisseur SMS de ce domaine, depuis un numéro de son pool qu'aucun client ne détient (ou celui du paramètre de domainesettings.verification.sms_from) ; sans numéro utilisable :503 sms_unavailable.country(code ISO du pays) précise le pays d'une ciblesms; il se déduit de l'indicatif régional pour un numéro nord-américain.POST /auth/verifications/<id>/confirm{code}répond{verification: {id, verified: true}, verified_token, expires_in}. Leverified_tokenest à usage unique et vaut 10 minutes.PUT /auth/me/email{email, password, verified_token}ouPUT /auth/me/phone{country, number, password, verified_token}appliquent le changement. Le changement de courriel met fin aux autres sessions et renvoie de nouveaux jetons comme le changement de mot de passe, annule les liens de réinitialisation en attente et prévient l'ancienne adresse ; le changement de téléphone posephone.verified_at.
Une inscription libre dont l'adresse n'est pas prouvée (user.email_verified faux) prouve son
adresse avec purpose: "email_verify" (sans target, ou avec sa propre adresse ; 409
already_verified une fois prouvée) puis /confirm : la réponse porte email_verified: true et
credit_granted quand un crédit de départ attendait (voir Inscription).
Anti-abus : 60 secondes entre deux codes pour une même cible, au plus 3 codes par heure par
usager et cible, 10 par usager et 6 par boîte courriel (tous usagers : les écritures d'une même
boîte, avec +étiquette ou, chez Gmail, avec des points, comptent pour une), 5 essais par code. Au-delà :
429 rate_limited avec retry_after. Erreurs propres à ces routes :
| Code HTTP | error |
Sens |
|---|---|---|
| 400 | invalid_code |
code faux (avec attempts_left), ou verified_token inconnu, déjà utilisé ou prouvant une autre cible |
| 400 | code_expired |
code ou verified_token de plus de 10 minutes |
| 400 | verification_required |
verified_token absent |
| 400 | invalid_phone |
numéro ou pays non acceptable |
| 401 | invalid_password |
mot de passe actuel incorrect |
| 409 | email_in_use |
adresse déjà celle d'un autre compte |
| 409 | already_verified |
vérification déjà confirmée (demandez un nouveau code) |
| 429 | too_many_attempts |
5 essais utilisés (demandez un nouveau code) |
| 429 | rate_limited |
trop de codes demandés |
| 502 | send_failed |
le serveur de courriel ou le fournisseur SMS a échoué |
| 503 | email_unavailable |
aucun serveur SMTP pour le domaine |
| 503 | sms_unavailable |
aucun numéro ni fournisseur SMS pour envoyer le code |
Clé API
Pour un accès programmatique, une clé API remplace le JWT :
Une clé se crée par POST /api/v1/keys avec un jeton JWT d'un membre qui a la permission
api_keys.manage (profil Admin) : une requête authentifiée par une clé y reçoit 403, une clé n'en crée donc jamais une
autre. Le corps doit être un objet JSON avec un name non vide ; team_id, s'il est donné, est
l'identifiant (une chaîne) d'une équipe que vous atteignez : sinon, 400 validation_error. Une clé
qui n'est pas limitée à une équipe a les droits de son créateur : elle ne se crée qu'avec le
mot de passe actuel du propriétaire (current_password : 400 validation_error s'il manque,
401 invalid_password s'il est faux ; un compte sans mot de passe, connecté par OAuth, n'en donne
pas), à raison de 10 essais par heure. Toute clé expire : expires_in_days (entier de 1 à 365, 90
par défaut) fixe sa date d'expiration (expires_at), après laquelle elle reçoit 401 ; il faut alors en créer une nouvelle.
Dans l'application, ces clés se gèrent sous Paramètres → Intégrations → Clés d'administration. Une
clé personnelle est révoquée quand son propriétaire change de mot de passe ou de courriel,
réinitialise son mot de passe ou est désactivé. Le secret n'est montré qu'une fois, à la création, dans le champ key de la réponse 201 ({api_key, key, message, warning} : api_key décrit la clé, sans son secret ; une intégration, elle, rend le sien dans api_key). Une clé inconnue ou révoquée, l'ancienne clé d'une
intégration après régénération, ou la clé d'une intégration désactivée ou supprimée reçoit
401 {"error": "unauthorized", "message": "Invalid API key"}, sauf sur les routes fermées à toute
clé (ci-dessous), qui répondent 403 dès qu'un en-tête X-API-Key est présent.
Il y a deux sortes de clés :
- une clé ordinaire (créée sans
team_id) a les droits que les profils de son créateur lui donnent, jugés comme pour sa session, mais jamais ses permissions de plateforme : la clé d'un compteplatform_adminoudomain_adminn'a que les permissions de ses propres profils dans son organisation courante (le profil Utilisateur s'il n'y est pas membre), ni la plateforme ni un domaine (sa session les garde) ; - une clé restreinte est limitée à une équipe (
team_idà la création), ou c'est la clé d'une intégration. Elle n'a que les droits du profil Utilisateur sur les équipes qu'elle atteint, quels que soient les profils de son créateur : lire les conversations et envoyer, rien administrer. L'accès à une équipe reste jugé avec les droits actuels du créateur : s'il perd l'équipe, la clé la perd aussi.
Une clé restreinte reçoit 403 sur toute route qui exige plus que les permissions du profil Utilisateur (par exemple la
liste des conversations fermées d'une équipe, GET /api/v1/teams/<team_id>/archives), et sur les routes qui touchent à toute l'organisation ou au
compte de son créateur : facturation, organisations, voix, notifications push, essai gratuit et
état du compte, et GET /api/v1/phone-numbers/unassigned. Le corps est alors
{"error": "forbidden", "message": "API key not allowed on this route"} (quelques contrôles propres
à une route gardent leur message).
Certaines routes refusent toute clé, ordinaire ou restreinte, avec ce même corps. Pour les
intégrations (/api/v1/integrations*), la facturation d'un domaine
(/api/v1/domains/<id>/billing-settings, payment-setup, promo-codes, payments, monthly-fees,
subscription-settings, billing-overview, organizations/<org_id>/subscription), les profils et les
permissions de plateforme (/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), le second facteur et les clés d'accès (/auth/mfa*,
/auth/webauthn/*) et l'API interne des serveurs voix
(/api/v1/internal/voice/*, réservée à leur jeton de service), ce 403 répond dès qu'un en-tête
X-API-Key est présent, même invalide. Pour la recherche, l'achat et la gestion des numéros (prix,
libération, restauration, modification), les codes d'appel de la voix, les routes personnelles de la
voix (/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)
et la création d'une clé, il répond à une clé valide ; une clé inconnue ou révoquée y reçoit 401,
comme partout ailleurs.
Aucune clé, même ordinaire, ne donne un autre moyen de se connecter. Ce même 403 répond, dès qu'un
en-tête X-API-Key est présent (même invalide), à ce qui créerait ou renforcerait un accès :
POST /api/v1/users(créer un compte, avec ou sans mot de passe),POST /api/v1/invitationsetPOST /api/v1/invitations/<id>/resend(renvoyer le courriel d'une invitation) ;POST /api/v1/invitations/accept(accepter une invitation : réservé à la session du compte invité) ;POST /api/v1/organizations/<id>/members/<user_id>/impersonate(ouvrir une session en tant qu'un membre, voir Session ouverte par un administrateur) ;PUT /api/v1/users/<id>quand le corps contientpassword, quel que soit l'usager (le créateur de la clé compris) ; son propre mot de passe ne se change d'ailleurs que parPUT /auth/me/password(403 ailleurs, même avec une session) ;- un corps qui donne le rôle
admin(donc le profil Admin) ou un rôle de plateforme :PUT /api/v1/users/<id>,PUT /api/v1/users/<id>/role,PUT /api/v1/organizations/<id>/members/<user_id>; POST /api/v1/domains/<id>/adminsetDELETE /api/v1/domains/<id>/admins/<user_id>;- la modification des réglages SMTP et OAuth (
PUT /api/v1/domains/<id>/settings/smtpet son testPOST .../settings/smtp/test,PUT /api/v1/domains/<id>/settings/oauth/<fournisseur>,PUT /api/v1/system/smtp,PUT /api/v1/system/oauth/<fournisseur>), qui portent les liens de réinitialisation du mot de passe et décident de qui peut se connecter, et celle des comptes qui doivent avoir un second facteur (PUT /api/v1/domains/<id>/security-settings) ; PUT /api/v1/domains/<id>quand le corps contienthostnamesouname: les noms d'hôte du domaine donnent l'adresse des liens de ses courriels de connexion.display_nameetcapabilitiesrestent modifiables avec une clé ordinaire ;- les routes qui touchent tout le domaine :
PUT /api/v1/domains/<id>/account-settings,PUT /api/v1/domains/<id>/settings/payment-providers/<fournisseur>,PUT /api/v1/domains/<id>/settings/default-payment-provider,PUT /api/v1/domains/<id>/sms-providers/<fournisseur>,POST /api/v1/domains/<id>/webhook/regenerate, le crédit ajouté à la main (POST /api/v1/billing/admin/add-credits,POST /api/v1/domains/<id>/organizations/<org_id>/credit) et les règles de sortie des appels d'une organisation (PUT /api/v1/domains/<id>/organizations/<org_id>/voice-outbound) ; - les comptes fournisseurs SMS et les trunks voix, de la plateforme comme d'un domaine (création,
modification, suppression, secrets, inventaire de numéros, domaines autorisés :
/api/v1/system/sms-accounts*,/api/v1/domains/<id>/sms-accounts*,/api/v1/system/voice-trunks*,/api/v1/domains/<id>/voice-trunks*en écriture).
La liste exacte, route par route, est générée depuis le code : voir Clés API : routes fermées dans la référence.
Ces opérations restent ouvertes à une session (JWT). Une clé ne peut donc ni créer une autre clé, ni obtenir une session qui en créerait une.
Une clé ne passe jamais par le second facteur : c'est un secret à part, créé
par une session (qui, elle, a pu exiger le second facteur). Les routes authentifiées de /auth
(GET /auth/me compris) n'acceptent qu'un jeton JWT : avec une clé seule, elles répondent 401 (403 pour
/auth/mfa* et /auth/webauthn/*, fermées à toute clé).
GET /api/v1/teams renvoie à une clé restreinte exactement les équipes qu'elle atteint dans le
domaine courant, sans leur webhook_secret. Avec n'importe quelle clé, GET /api/v1/phone-numbers
ne liste que les numéros assignés à une équipe que la clé atteint. Pour une clé restreinte, ces deux
listes et GET /api/v1/phone-numbers/<id> partent des équipes de la clé, quelle que soit
l'organisation dans laquelle son créateur travaille en ce moment dans l'application.
Comme il n'y a pas de session, certaines routes (par exemple GET /api/v1/teams) acceptent un
paramètre organization_id pour préciser l'organisation visée ; avec une clé restreinte, il ne fait
que filtrer ses équipes (jamais de 403). Avec une clé ordinaire, comme avec un JWT, ce doit être une
organisation dont le créateur de la clé est membre ou, s'il est domain_admin, une organisation de
ses domaines (un super_admin les atteint toutes) : sinon 403, quel que soit X-Frontend-Host.
GET /api/v1/teams ne donne le webhook_secret d'une équipe qu'aux membres qui ont teams.provider dans
son organisation (profils Admin et Manager), et à ceux qui l'administrent (platform_admin, domain_admin de
son domaine).
X-Frontend-Host (voir Multi-domaine) est facultatif avec une clé ; s'il est
présent, ce doit être l'hôte de l'application Telma de l'organisation, sinon la clé ne voit aucune
équipe (listes vides, 403 sur une équipe).
Gardez la clé sur votre serveur : voir Sécurité.
Inscription
Par défaut il n'y a pas d'inscription libre : POST /auth/register exige un jeton d'invitation.
Un domaine peut ouvrir l'inscription libre (réglage signup.enabled, fermé par défaut) :
GET /auth/signup/config (public) dit si elle est ouverte et quel captcha afficher, puis
POST /auth/signup {email, password, first_name, last_name, phone?, language, accept_terms,
captcha_token?} crée le compte, son organisation, son équipe par défaut. Le courriel n'est pas
prouvé à l'inscription : le crédit de départ du domaine (initial_credit) n'est donc accordé,
une seule fois, qu'une fois l'adresse vérifiée (purpose: "email_verify", voir
Vérification par code) ou changée pour une adresse
vérifiée. Voir Compte : inscription, essai, suppression, notifications.
Avec une invitation : le jeton peut venir d'une invitation d'organisation (l'utilisateur rejoint cette organisation
avec les profils de l'invitation) ou d'une invitation d'administrateur de domaine (l'utilisateur devient
domain_admin du domaine, sans organisation propre). GET /api/v1/invitations/verify/<token>
permet d'afficher la destination avant de créer le compte. Le mot de passe choisi suit la règle de
force de PUT /auth/me/password (400 validation_error, field: password).
Une invitation à une adresse qui a déjà un compte n'ajoute jamais ce compte à l'organisation : la
réponse de POST /api/v1/invitations est la même (aucun profil), et le titulaire du compte accepte
lui-même, connecté, par POST /api/v1/invitations/accept {token} (le courriel mène à la page qui le
fait). Seule la session du compte invité accepte ; un jeton d'une autre adresse reçoit 404.
Courriels de connexion
Les courriels qui portent un moyen de se connecter (réinitialisation du mot de passe, invitation
d'organisation, création comme renvoi, et invitation d'administrateur de domaine) partent par le
serveur SMTP du domaine du compte visé, et leur lien mène au premier nom d'hôte de ce domaine qui
n'est pas localhost. Ce domaine ne dépend jamais du seul X-Frontend-Host de la requête ; quand
aucun domaine ne convient, c'est la plateforme :
| Courriel | Domaine utilisé |
|---|---|
réinitialisation (POST /auth/forgot-password) d'un compte dont toutes les organisations (courante et adhésions) sont dans un même domaine |
ce domaine |
réinitialisation d'un domain_admin qui n'administre qu'un domaine et n'a d'organisation que dans celui-ci, demandée depuis ce domaine (X-Frontend-Host) |
ce domaine |
réinitialisation d'un super_admin, d'un domain_admin dans les autres cas, ou d'un compte dont les organisations couvrent plusieurs domaines, aucun, ou une organisation sans domaine |
aucun : la plateforme |
invitation d'organisation (POST /api/v1/invitations, POST /api/v1/invitations/<id>/resend) |
le domaine de l'organisation (la plateforme si elle n'en a pas) |
invitation d'administrateur de domaine (POST /api/v1/domains/<id>/admins) |
le domaine invité |
Un domaine sans serveur SMTP configuré passe par celui de la plateforme, et un domaine sans nom
d'hôte de production donne un lien vers l'adresse de la plateforme. La plateforme, ce sont les
variables d'environnement du serveur Telma : SMTP_HOST, SMTP_PORT, SMTP_USERNAME,
SMTP_PASSWORD, SMTP_FROM_EMAIL, SMTP_FROM_NAME, SMTP_USE_TLS pour le serveur, et
FRONTEND_URL pour l'adresse des liens. Sans serveur configuré, rien n'est envoyé. Pour une
adresse courriel valide, POST /auth/forgot-password répond 200 avec le même message, qu'elle
corresponde à un compte ou non, et quel que soit le domaine choisi.
Les administrateurs d'un domaine règlent son serveur SMTP et ses noms d'hôte : ils ne reçoivent ainsi jamais le lien d'un compte que leur domaine ne couvre pas en entier.
Sécurité de la connexion
- Essais de mot de passe.
POST /auth/loginne compte que les échecs (401) : 30 par 15 minutes et par adresse courriel, puis429 rate_limitedavecRetry-After, même avec le bon mot de passe (le budget borne les essais). Quand l'adresse du client est connue : 10 échecs par 15 minutes par couple courriel et adresse, et 100 par adresse ; une adresse ne peut donc ni verrouiller un compte pour les autres ni arroser plusieurs comptes. Les 4ᵉ échecs et suivants d'un compte sont répondus de plus en plus lentement (0,5 s, doublé à chaque échec, jusqu'à 4 s). Une connexion réussie ne compte jamais. Un compte inconnu, de service ou sans mot de passe coûte le même calcul qu'un compte connu. - OAuth (Google, Microsoft, Apple).
GET /auth/oauth/<fournisseur>?redirect_uri=renvoie{auth_url, state}: lestateest fait et signé par le serveur (10 minutes, usage unique, lié au fournisseur, au domaine et à laredirect_uri) et l'adresse porte un défi PKCE (S256). Le client garde lestate(sessionStorage), refuse un retour dont lestatediffère, et l'envoie avec lecodeàPOST /auth/oauth/<fournisseur>/callback(sans lui :400 oauth_error, rien n'est envoyé au fournisseur). Le compte est celui lié à l'identité du fournisseur (subde Google,idde Microsoft,subd'Apple). Une adresse n'est rattachée à un compte existant que si Google attesteemail_verified; Microsoft n'atteste rien, donc jamais de rattachement ni d'invitation d'administrateur de domaine par l'adresse. Un compte n'est créé que si l'inscription libre du domaine est ouverte, dans ce domaine. Tout échec est le même400 oauth_errorau message générique. Une personne peut lier Google, Microsoft et Apple au même compte : une identité d'un autre fournisseur dont l'adresse est attestée s'ajoute à côté de la première (oauth_identities) ; une autre identité du même fournisseur avec la même adresse est refusée. Google et Microsoft passent toujours par les identifiants de la plateforme : un domaine ne fait que les activer (PUT /api/v1/domains/<id>/settings/oauth/google|microsoft{enabled}, tout autre champ est ignoré). - Apple (« Sign in with Apple »). Mêmes règles, avec trois différences :
GET /auth/oauth/apple?redirect_uri=reçoit la page web où le navigateur doit revenir (une adresse d'un domaine connu) ; Apple renvoie le navigateur par un formulaire (form_post, obligatoire pour obtenir le nom et l'adresse) versPOST /auth/oauth/apple/returnde l'API, qui le renvoie (303) vers cette page aveccode,stateet, la première fois seulement,user(le nom) ; la page envoiecode,stateetuseràPOST /auth/oauth/apple/callback. Ce qu'Apple dit de la personne est l'id_tokende l'échange, vérifié avec les clés publiques d'Apple (signature, émetteur, identifiant client, expiration etnoncelié austate). Apple atteste l'adresse, y compris une adresse relais (…@privaterelay.appleid.com). Identifiants : ceux du domaine (PUT /api/v1/domains/<id>/settings/oauth/apple:client_id= Services ID,team_id,key_id,private_key= la clé .p8), chacun absent pris dans la configuration de la plateforme ; Apple n'est proposé (GET /auth/oauth/providers) que si les quatre sont connus. Le secret client est un jeton ES256 de 5 minutes signé avec la clé, fait à chaque échange. - Second facteur. Un compte peut avoir une application d'authentification (TOTP, 6 chiffres) et des
clés d'accès (WebAuthn : empreinte, visage, code de l'appareil, clé de sécurité USB ou NFC), plus 10
codes de secours à usage unique (donnés une fois, gardés hachés). Avec un second facteur, le bon mot
de passe (ou OAuth) ne donne pas la session : la réponse est
{mfa_required: true, mfa_token, methods, expires_in}. Lemfa_token(5 minutes, usage unique, mort au changement de mot de passe, jamais un jeton de session) part avec un code ou une assertion de clé d'accès àPOST /auth/mfa/verify(method=totp,backup_codeouwebauthn; options de la clé :POST /auth/mfa/webauthn/options) ; 10 échecs par 15 minutes et par compte. Un même code TOTP ne sert qu'une fois. La session ainsi ouverte porte la revendicationmfa: true. - Connexion par clé d'accès.
POST /auth/webauthn/login/optionspuisPOST /auth/webauthn/login: sans adresse ni mot de passe, la clé dit le compte ; l'appareil doit avoir vérifié la personne (empreinte, visage, code), la connexion vaut donc un second facteur. Une clé est liée au site où elle a été créée (l'Originde la requête, qui doit être un hôte d'un domaine connu). L'application mobile n'envoie pas d'OriginmaisX-Client-App: iosouandroidavecX-Frontend-Host: la clé est alors celle du site de cet hôte (le site déclare l'application dans/.well-known/apple-app-site-associationet/.well-known/assetlinks.json), et une clé créée sur le site sert aussi dans l'application. Gestion :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. Retirer un facteur ou refaire les codes demande un code (TOTP ou secours), le mot de passe seulement pour un compte qui n'a que des clés d'accès. Fermé aux clés API et aux sessions ouvertes en tant que membre ; chaque changement est journalisé (mfa_factor_added,mfa_factor_removed,mfa_backup_code_used). - Second facteur des administrateurs : recommandé, bientôt obligatoire. Il est attendu des admins de
la plateforme et des domaines, et des admins d'organisation (
members.admin) des domaines qui le demandent (PUT /api/v1/domains/<id>/security-settings {mfa_organization_admins}; sa lecture donne aussimfa_enforced, faux tant que l'obligation n'est pas activée). Aujourd'hui il n'est que recommandé : la connexion d'un tel compte sans second facteur ouvre la session avecmfa_setup {required: true, reason, deadline: null}(l'interface affiche un bandeau « va devenir obligatoire ») et rien n'est jamais refusé. Quand l'obligation sera activée sur la plateforme, il y aura un délai de grâce de 14 jours (MFA_GRACE_DAYS) depuis la première fois où le compte a été vu sans : pendant la grâce,mfa_setupporte ladeadline; après, le mot de passe ne donne qu'unmfa_tokend'inscription (mfa_enrollment_required, 15 minutes), à mettre dans l'en-têteX-MFA-Tokendes routes d'ajout d'un facteur (/auth/mfa/totp/*,/auth/webauthn/register*) : la route qui ajoute le premier facteur répond avec la session. Une session ouverte sans second facteur ne sera plus renouvelée (POST /auth/refresh: 401) une fois la grâce finie, et le dernier facteur d'un tel compte ne pourra pas être retiré (409 mfa_required). Un client doit donc déjà savoir traitermfa_requiredetmfa_enrollment_required. - En-têtes. Toute réponse de l'API porte
X-Content-Type-Options: nosniffetReferrer-Policy: no-referrer; les réponses de/auth/*portent aussiCache-Control: no-store. La politique de sécurité du contenu (CSP) des pages relève du serveur web du frontend, pas de l'API. - CORS. L'API ne reflète une origine que si son nom d'hôte est configuré : hôtes des domaines
(base de données) ou
CORS_ORIGINS.localhost,127.0.0.1,10.x,192.168.xet*.textos.cane valent qu'en développement et en test (FLASK_ENV). - Journal. Les écritures faites avec
X-Admin-Organizationsont journalisées une à une ; les lectures le sont en une entrée par administrateur, organisation et quart d'heure (admin_read, avec le nombre de lectures).
Les routes du second facteur et des clés d'accès répondent {error, message} avec ces codes :
| Code HTTP | error |
Sens |
|---|---|---|
| 400 | proof_required |
retirer un facteur ou refaire les codes de secours sans code (ni password pour un compte qui n'a que des clés d'accès) |
| 400 | invalid_code |
code TOTP ou de secours faux en gestion ; à POST /auth/mfa/verify, un code faux répond 401 invalid_code |
| 400 | origin_not_allowed |
Origin (ou, pour l'app, X-Frontend-Host avec X-Client-App) qui n'est pas un hôte d'un domaine connu |
| 401 | invalid_mfa_token |
mfa_token ou X-MFA-Token inconnu, expiré, déjà utilisé, ou mort au changement de mot de passe |
| 401 | unknown_credential |
clé d'accès inconnue, ou signature refusée |
| 401 | invalid_password |
mot de passe faux donné comme preuve |
| 401 | unauthorized |
ni session ni X-MFA-Token valide sur une route de gestion |
| 403 | second_factor_required |
ajouter un facteur à un compte qui en a déjà un exige une session qui a prouvé un second facteur (mfa: true) |
| 403 | impersonation_forbidden |
session ouverte en tant qu'un membre |
| 404 | not_found |
clé d'accès inconnue (renommer, retirer) |
| 409 | mfa_required |
retrait du dernier facteur d'un compte qui doit en avoir un (seulement quand l'obligation est activée) |
| 409 | already_enabled, not_started, mfa_not_enabled |
application d'authentification déjà active, confirmation sans setup, codes de secours demandés sans second facteur |
| 409 | already_registered, too_many_passkeys, no_passkey |
clé d'accès déjà enregistrée, déjà 20 clés pour le compte, aucune clé d'accès du compte pour ce site (POST /auth/mfa/webauthn/options) |
Multi-domaine
Chaque site (whitelabel) est un domaine : app.telma.ca, other.telma.ca, ...
L'API résout le domaine à partir de l'en-tête X-Frontend-Host, ou à défaut de l'en-tête
Host :
Le domaine courant détermine :
- les organisations visibles et administrables ;
- la configuration appliquée (SMTP, OAuth, fournisseurs SMS, image de marque, paiement), sauf pour les courriels de connexion, qui suivent le domaine du compte visé ;
- les numéros de téléphone et les fournisseurs disponibles.
Les applications mobiles doivent envoyer cet en-tête.
Organisation courante et organisation par défaut
Un utilisateur peut appartenir à plusieurs organisations, dans plusieurs domaines.
GET /api/v1/organizations/current retourne l'organisation active pour le domaine visité :
si l'organisation active de l'utilisateur appartient à un autre domaine, l'API bascule
automatiquement sur son organisation par défaut pour ce domaine, sinon sur la première
organisation du domaine à laquelle il a accès.
L'organisation par défaut se règle par domaine avec PATCH /auth/me et le champ
default_organization_id ; l'organisation doit appartenir au domaine visité, et null (ou une
chaîne vide) efface le défaut de ce domaine. La réponse
user expose default_organization_id (le défaut du domaine courant) et
default_organization_ids (tous les domaines).
Rôles et permissions
Depuis le 2026-10-16, les droits viennent de profils et de permissions (les anciens rôles
agent, manager, admin restent dans les réponses, déduits des profils, le temps de la
transition ; ils ne décident plus de rien : seules les permissions comptent, et le champ role
d'un compte de plateforme vaut super_admin ou domain_admin d'après ses permissions de plateforme).
- Permissions de plateforme, portées par le compte :
platform_admin(tout le système : l'anciensuper_admin),domain_admin(les domaines dedomain_ids: toutes leurs organisations),support. Elles se donnent avecPUT /api/v1/users/<id>/platform-permissions{permissions, domain_ids?}(platform_adminseulement ; les sessions du compte se ferment). - Permissions d'une organisation : 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). Elles viennent des profils du membre, qui s'additionnent. - Profils : deux profils de base non modifiables,
builtin:admin(toutes les permissions) etbuiltin:user(messages.read,messages.send,metrics.team,voice.use) ; ceux d'un domaine (/api/v1/domains/<id>/profiles, administrés par la plateforme et les admins de ce domaine, offerts à ses organisations) et ceux de l'organisation (/api/v1/organizations/current/profiles: lecture avecmembers.manageoumembers.admin, écriture avecmembers.admin; un profil encore porté par un membre ne se supprime pas,409 profile_in_use). Une organisation qui avait des managers a reçu un profil « Manager » aux droits exacts de l'ancien rôle. Les profils d'un membre se posent avecPUT /api/v1/organizations/current/members/<user_id>/profiles(members.admin;409 last_admins'il ne restait plus personne avecmembers.admin,402 plan_limitau-delà des utilisateurs du forfait) et une invitation peut en porter (profile_idsdePOST /api/v1/invitations). Changer les permissions d'un profil se juge de la même façon pour tous ceux qui le portent :402 plan_limitsi des membres se mettraient à utiliser les messages au-delà du forfait,409 last_adminsi une organisation n'aurait plus personne avecmembers.admin, et rien n'est modifié. Pour un profil de domaine, le refus nomme les organisations en cause (organizations:{id, name, error, limit, plan, upgrade, current}). metrics.allouvre les métriques de toutes les équipes ;metrics.teamseule, celles de l'équipe où l'on est placé (une autre équipe : 403). La liste des membres (GET /organizations/current/members,membersdeGET /organizations/<id>) exigemembers.admin.- Agir sur un membre (le lire, changer son équipe, son rôle ou ses profils, le retirer, ses clés API, ses
statistiques) :
members.admindans l'organisation, oumembers.managepour un membre de son équipe qui n'a nimembers.managenimembers.admin, et seulement si l'on détient aussi chacune de ses permissions là : on n'agit jamais sur quelqu'un qui peut faire plus que soi. Il n'y a pas de hiérarchie de rôles. De même, on ne donne (rôle, profils d'un membre ou d'une invitation) que des permissions qu'on détient (403 forbidden,fieldroleouprofile_ids) ; avecmembers.manageseul, jamaismembers.managenimembers.admin. Widgets et intégrations :integrations.manage; le secret de réception d'une équipe :teams.provider. GET /auth/medonneplatform_permissions,organization_permissions(organisation courante) etprofile_ids;GET /api/v1/organizations/mydonnepermissionspour chaque organisation.
Une route juge la permission dans l'organisation de la ressource (équipe, conversation, message),
sinon dans l'organisation courante. Un compte platform_admin ou domain_admin passe ces contrôles (les
routes d'un domaine et le contexte d'administration bornent sa portée) ; un compte
sans adhésion dans son organisation courante (le compte de service d'une intégration) a le profil
Utilisateur ; une clé API restreinte (limitée à une équipe, ou d'une intégration) a les permissions du profil
Utilisateur, une clé ordinaire celles de son créateur sans ses permissions de plateforme (voir Clé API).
Les routes de la plateforme et des domaines demandent une permission de plateforme (domain_admin : seulement
pour ses domaines). Sans la permission : 403 forbidden. La permission de chaque opération est indiquée dans la
référence.
Voix (VoXoS)
Les routes /api/v1/voice/* ne servent que les organisations qui portent la capacité voxos
(sinon 403 voice_not_enabled) et ne montrent que les ressources de l'organisation courante.
Les droits dépendent des permissions tenues dans l'organisation (profils) ; la référence donne celle de chaque route :
| Ressource | Lecture (GET) |
Écriture |
|---|---|---|
numéros (/voice/numbers), flux (/voice/flows), groupes (/voice/groups), sons (/voice/sounds et le fichier d'un son), invites standard du serveur voix (/voice/system-prompts*), limites et tarifs (/voice/limits, /voice/rating/*) |
voice.use (la synthèse vocale GET /voice/sounds/tts : voice.routing) |
voice.routing |
appels (/voice/calls), statistiques (/voice/stats, with_stats de /voice/numbers) |
voice.use : les appels de ses propres postes (passés ou sonnés) ; avec voice.calls.all : tous les appels ; endpoint_id : seulement les appels de ce poste, qui doit être à l'usager (404 sinon, même avec voice.calls.all) |
— |
messagerie (/voice/voicemails) |
boîte d'un numéro : voice.calls.all ou voice.routing, et le membre dont un poste est sonné par le routage du numéro (directement, par un groupe d'appel, un de ses étages ou son repli, ou par un flux d'appel, renvois goto compris) ; boîte d'un poste : son usager et voice.routing ; boîte indépendante : ses membres et voice.routing |
marquer écouté : qui peut l'entendre ; supprimer : voice.routing, ou un auditeur d'une boîte de poste ou indépendante |
boîtes vocales (/voice/mailboxes, capacité voicemail_boxes) |
les boîtes que l'usager écoute (toutes avec voice.routing) |
voice.routing |
postes SIP (/voice/endpoints, mots de passe SIP inclus) et rapports de diagnostic (GET, DELETE /voice/diagnostics*) |
voice.endpoints |
voice.endpoints |
liste noire (/voice/blacklist) |
voice.blacklist |
voice.blacklist |
enregistrements (/voice/calls/<id>/recording, /voice/recordings) |
voice.recordings |
voice.recordings |
musique d'attente (/voice/moh, musiques /voice/moh/tracks*, listes /voice/moh/classes*, choix /voice/moh/settings, /voice/moh/numbers/<id>) |
voice.use (la bibliothèque libre de la plateforme, les musiques et listes de l'organisation, le choix) |
voice.routing |
/voice/provision (compte SIP de l'usager), /voice/my-endpoints (ses postes : ne pas déranger, renvois, suivez-moi), /voice/call-info, l'enregistrement réglé depuis l'app (/voice/call-info/recording*), l'annuaire (/voice/directory) et l'envoi d'un rapport de diagnostic (POST /voice/diagnostics) |
l'usager lui-même (voice.use, sauf /voice/provision), pour les postes qui lui sont assignés |
l'usager lui-même |
Musique d'attente. GET /voice/moh donne la bibliothèque libre de la plateforme (auteur et licence de chaque
musique), les musiques et listes de lecture de l'organisation, son choix et celui de ses numéros. Une musique téléversée
(POST /voice/moh/tracks, multipart) exige rights_confirmed: true (droits de diffusion : qui et quand sont gardés) ;
une liste de lecture (/voice/moh/classes) réunit des musiques de la bibliothèque et de l'organisation. Le choix
(PUT /voice/moh/settings {class_id, ringing}, PUT /voice/moh/numbers/<id> pour un numéro, null = celui de
l'organisation) : la liste jouée quand un poste met l'appel en garde, et, avec ringing: true, à la place de la
sonnerie pendant que les postes sonnent ; sans choix, la musique par défaut de la plateforme. Depuis un poste, le 602
fait entendre la musique de l'organisation.
Formats audio. Tout l'audio stocké est en MP3 : les sons (téléversés en wav, mp3, m4a, ogg ou webm, enregistrés au
micro ou faits par synthèse vocale, convertis à la création ; original_extension garde le format reçu), la musique
d'attente, les messages vocaux et les enregistrements d'appels.
Historique d'un poste (app). GET /voice/calls?endpoint_id=<id> ne donne que les appels de ce poste (passés,
ou qui l'ont fait sonner) ; le poste doit être assigné à l'usager (404 sinon, quelles que soient ses permissions).
Chaque appel porte alors mine {direction, number, name, answered} : l'appel tel que le poste l'a vécu (sortant
ou entrant, l'autre partie, et s'il a été répondu par ce poste).
Enregistrement depuis l'app. L'usager règle l'enregistrement des appels que passe son poste
(PUT /voice/call-info/recording?endpoint_id= avec {enabled}). L'organisation peut l'imposer sur les appels
sortants par GET|PUT /api/v1/domains/<id>/organizations/<id>/voice-recording
({outbound_always, outbound_follows_number, recording_skip_allowed}, admin de domaine ou de plateforme,
éteint par défaut) : ses règles gagnent alors sur le réglage du poste. Quand l'enregistrement est imposé et
que recording_skip_allowed le permet (par défaut), PUT /voice/call-info/recording/skip-next?endpoint_id=
avec {skip} saute l'enregistrement du prochain appel sortant seulement (valable 15 minutes, utilisé une
fois) ; sinon 403 recording_skip_not_allowed, ou 409 recording_not_imposed quand rien ne l'impose.
GET /voice/call-info donne recording_detail (endpoint, imposed_by, skip_allowed, skip_next).
Ces changements sont journalisés.
Invites standard (flux d'appel). Les étapes play et menu d'un flux acceptent, à la place d'un son de
l'organisation (sound_id, qui gagne s'il est donné), system_sound : 1 à 6 invites du serveur voix jouées à
la suite, chacune un nom du catalogue GET /voice/system-prompts (les invites proposées avec leur texte fr et
en ; un autre nom donne 400 validation_error, field = steps[i].system_sound), fr: ou en: devant le nom
pour fixer sa langue, ou un objet {name, only: ["fr"]} joué seulement quand l'appel est dans une de ces
langues. GET /voice/system-prompts/<nom>/audio?language=fr|en fait écouter une invite (catalogue seulement).
Les étapes dial et groupdial acceptent wait (ring ou music : sonnerie ou musique d'attente pendant
que les postes sonnent ; absent, le réglage du numéro), et l'étape menu extension_dial: true (l'appelant
compose le numéro court d'un poste de l'organisation ; une touche du menu gagne).
Appareils par poste. Plusieurs appareils peuvent être connectés en même temps au même poste et sonnent
ensemble : 3 par défaut (réglage de la plateforme), max_contacts de 1 à 10 sur un poste
(POST|PUT /voice/endpoints, null = la valeur de la plateforme) ; au-delà, l'enregistrement le plus ancien
est remplacé.
Les postes SIP (/voice/endpoints*, /voice/my-endpoints*), le provisionnement et la lecture des
rapports de diagnostic exigent en plus la capacité sip (voir Capacités).
Les routes personnelles (GET|PUT /voice/call-info*, GET /voice/directory, POST /voice/diagnostics,
GET /voice/calls?endpoint_id=) partent d'un poste assigné à l'usager (activé, sauf pour l'historique), dans
n'importe laquelle de ses organisations (404 sinon), et jugent voice.use, voxos et sip dans
l'organisation de CE poste, pas dans l'organisation courante. L'usager règle aussi l'enregistrement de son
poste par PUT /voice/my-endpoints/<id>/settings (recording), comme depuis l'app. Les détails de coût
d'un appel (billing.costing) ne sortent jamais de l'API. Les clés API limitées à une équipe et celles
des intégrations n'ont jamais accès à /voice/* ; les routes personnelles (/voice/my-endpoints*, /voice/call-info,
POST /voice/diagnostics) refusent toute clé.
Sécurité de la voix (antifraude). Le serveur voix applique à tout appel vers le réseau public
(poste, renvoi, membre externe d'un groupe, code d'appel) les limites de l'organisation, sinon celles
du domaine : pays permis (Canada et États-Unis par défaut), numéros surtaxés refusés, heures permises
pour l'international (international_hours), boucles, coût maximum d'un appel (10 $), durée maximale
(4 h), plafond de dépense sur une fenêtre glissante (10 $ en 5 minutes) et 10 appels simultanés.
GET /voice/limits donne à l'organisation ce qui s'applique (limits) et
outbound_suspension : null, ou {reason, detail, at} quand ses appels sortants sont suspendus
(reason : spend = plafond de dépense, call_cost = un appel a atteint le coût maximum, manual =
un administrateur, platform = l'interrupteur général). Pendant une suspension, les appels sortants
sont refusés (les codes d'appel en 403 outbound_suspended) ; les appels entrants continuent.
Seul un admin de domaine ou de plateforme la lève :
POST /api/v1/domains/<id>/organizations/<id>/voice-outbound/resume (et .../suspend pour suspendre
à la main, avec une note facultative ; les deux sont journalisés). Réglages :
GET|PUT /api/v1/domains/<id>/voice-limits (domaine) et .../organizations/<id>/voice-limits
(organisation, vide = valeur du domaine). Les événements (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) sont dans
GET /api/v1/domains/<id>/voice-security (avec les organisations suspendues) et, pour le super admin,
GET|PUT /api/v1/system/voice-security (l'interrupteur général outbound_suspended) ; chacun part
aussi par courriel aux admins de la plateforme et du domaine (une suspension aussi aux membres de
l'organisation qui ont organization.settings), au plus une fois par heure pour une même organisation et un même type. Un poste
peut limiter ses appels sortants à des réseaux (settings.allowed_networks, CIDR) ; après 20 mots de
passe faux en 10 minutes, il est verrouillé (locked {until, at, failures}) et
POST /voice/endpoints/<id>/unlock le libère.
Capacités
Une capacité est un service qu'un domaine offre et qu'une organisation peut utiliser. Une
organisation ne porte qu'un sous-ensemble des capacités de son domaine. Un super admin ou un admin
de domaine les pose avec PUT /api/v1/organizations/<id>/capabilities (la liste remplace la
précédente) ; celles du domaine se posent avec PUT /api/v1/domains/<id> (ajouter sip,
call_codes ou voicemail_boxes à un domaine est réservé au super admin). Toutes les routes qui renvoient une
organisation ou un domaine (/organizations/current, /organizations/my, /domains/current...)
montrent la liste.
| Capacité | Ouvre |
|---|---|
textos |
la messagerie SMS/MMS |
voxos |
le service voix (/api/v1/voice/*) |
sip |
les postes SIP (/voice/endpoints*) et le provisionnement du softphone (/voice/provision) ; exige voxos |
call_codes |
les codes d'appel (hérités, sans interface : aucune application ne s'en sert plus) ; exige voxos |
voicemail_boxes |
les boîtes vocales des postes et les boîtes indépendantes (/voice/mailboxes) ; exige voxos |
Un nom inconnu, ou sip, call_codes ou voicemail_boxes sans voxos dans la même liste, est refusé :
400 validation_error (field: capabilities). Sans sip, les routes qui en ont besoin répondent
403 avec {"error": "capability_required", "capability": "sip", "message": ...} (et
voice_not_enabled sans voxos). Retirer sip à une organisation désactive ses postes SIP ; ils
sont réactivés quand sip revient. L'action de routage custom d'un numéro n'est acceptée que
pour les super admins et les admins de domaine (403 forbidden pour un membre de l'organisation, même avec voice.routing).
Contexte d'administration
POST /api/v1/organizations/switch écrit l'organisation courante sur l'usager : elle vaut alors
pour tous ses appareils et onglets. Pour administrer une organisation sans en devenir membre,
un super_admin ou un domain_admin envoie, sur chaque requête (jeton JWT), l'en-tête
Pour cette requête seulement, l'usager est vu comme travaillant dans cette organisation :
organization_id = l'organisation visée, team_id = null, permissions de plateforme conservées. Aucune
donnée de l'usager n'est modifiée, et un onglet par organisation est possible. Les routes sont
les mêmes (/organizations/current, /teams, /users, /voice/*, /billing/*...).
GET /auth/me, GET /organizations/current et GET /organizations/my ajoutent
admin_context: true et admin_organization_id quand l'en-tête est honoré.
| Cas | Réponse |
|---|---|
usager qui n'est ni super_admin ni domain_admin |
403 forbidden |
domain_admin et organisation hors de ses domaines |
403 forbidden |
| organisation inconnue ou identifiant invalide | 404 not_found |
| requête authentifiée par clé API (quelle qu'elle soit) | 403 forbidden |
| en-tête vide, ou requête sans jeton valide | ignoré (la route répond comme sans en-tête) |
Toute écriture (POST, PUT, PATCH, DELETE) faite avec l'en-tête est journalisée (collection
audit_logs) : usager, organisation visée, méthode, chemin, statut et date, sans corps de requête
ni paramètre d'URL. Les lectures réussies (GET) sont résumées en une entrée par administrateur,
organisation et quart d'heure (admin_read, avec le nombre de lectures). Socket.IO a son propre mode d'administration
(auth.admin_organization, voir Temps réel) ; l'organisation visée est jugée avec le domaine de l'hôte
(X-Frontend-Host) comme le reste de l'API, un super admin qui administre une organisation d'un
autre domaine envoie donc l'hôte de ce domaine.
Recherche d'organisations
GET /api/v1/organizations (super admin ou admin de domaine ; domaine courant) accepte q
(texte libre cherché dans le nom de l'organisation et dans le courriel de son propriétaire, sans
égard à la casse ni aux accents, 100 caractères au plus), skip et limit (50 par défaut, 100
au plus ; une valeur invalide est un 400 validation_error). Le tri se fait par nom. La réponse
est {organizations, total, skip, limit} où total est le nombre d'organisations après filtre.
Un compte platform_admin peut ajouter all_domains=true pour chercher dans tous les domaines : chaque
organisation porte alors domain_id, domain_name et domain_host (sans cette permission : 403).
Compte : inscription, essai, suppression, notifications
Tout ce qui suit est éteint ou neutre par défaut et se règle par domaine avec
GET/PUT /api/v1/domains/<id>/account-settings (admin du domaine) : Textos et VoXoS ne changent pas.
- Notifications :
GET/PATCH /auth/me/notificationsdonne et change ce que le compte reçoit :email.updates,survey,purchase,login(un courriel à chaque connexion, éteint par défaut),low_balanceet son seuillow_balance_threshold(null : celui de l'organisation),push.sms,missed_call,voicemail. Les courriels de sécurité et de service (mot de passe oublié, codes de vérification, invitation, avis de solde négatif, fin d'essai, suppression du compte...) sont toujours envoyés. Tout le code passe parshould_notify(user, kind)(services/notification_preferences.py, catalogue des messages dans ce module). - Préférences libres :
GET/PATCH /auth/me/preferences, un petit dépôt clé/valeur (50 clés, 8 Ko) pour les applications ; une valeurnullretire la clé. - Suppression de son compte :
DELETE /auth/me {password, confirm: true}. Le compte seul de son organisation la ferme avec lui (refus409 balance_negativesi le solde est négatif) ; ses numéros retournent dans la réserve du domaine et ses clés, intégrations et postes SIP sont fermés. Un compte qui partage son organisation la quitte (refus403 last_adminpour le dernier membre au profil Admin d'une organisation qui a d'autres membres). Toutes les sessions se terminent. Pendant le délai de grâce du domaine (account_deletion.grace_days, 10 jours par défaut, 0 pour supprimer tout de suite), se reconnecter avec le mot de passe annule la suppression ; ensuite le compte est supprimé pour de bon. Refusé aux comptes de plateforme et en contexte d'administration. - Inscription libre : voir Inscription. Protections : captcha (fournisseur
réglé par domaine), au plus 5 comptes par adresse du client et par jour (seulement quand cette
adresse est connue, voir Limites de débit ; sinon un plafond large commun,
d'au moins 100 comptes par jour et par domaine), fournisseurs de courriel jetables refusés,
10 essais par heure et par courriel (20 par adresse du client connue). Le
409 email_in_usedit qu'une adresse a un compte : le formulaire d'inscription en a besoin, le captcha et ces limites bornent le sondage. Réponses :403 signup_disabled,409 email_in_use,400 captcha_failed,503 captcha_unavailable,429 rate_limited. - Essai gratuit :
GET /billing/trialdonne l'état,POST /billing/trial/activate {country, type?, area_code?, city?}donne un numéro du pool du domaine à l'organisation pourtrial.duration_daysjours (10 par défaut). Il exige un téléphone vérifié (un numéro de téléphone ouvre un essai par domaine). Quatre courriels aux membres qui ontbilling.manage(mi-parcours, 2 jours, 1 jour, fin) ; à la fin les numéros d'essai retournent dans la réserve. Atteindretrial.minimum_creditde solde convertit l'essai en compte normal.GET /organizations/current/statusrésume l'état du compte (active,suspendedoutrial).
Forfaits (abonnements)
Un domaine peut offrir des forfaits à ses organisations (GET|PUT /api/v1/domains/<id>/subscription-settings,
éteint par défaut) : des forfaits de messages (essential, team, enterprise, avec messages,
utilisateurs, équipes, numéros et widgets inclus, max_users, max_teams), des tranches de prix pour
les messages en plus, le système téléphonique (voice) et des options. L'organisation lit et choisit
le sien par GET|PUT /api/v1/billing/subscription (billing.manage pour choisir ; corps
{messaging_plan, voice}, les deux obligatoires : messaging_plan = essential, team, enterprise ou null
pour aucun, voice = booléen) et estime un choix par
GET /api/v1/billing/subscription/estimate?messaging_plan=&voice= (messaging_plan vide = le forfait actuel,
none = sans forfait ; un forfait inactif donne 400 validation_error).
L'admin de domaine utilise GET|PUT /api/v1/domains/<id>/organizations/<id>/subscription.
- Paiement d'avance sur le crédit. Le forfait est prélevé sur le crédit de l'organisation au jour
anniversaire, chaque mois (relevé
invoices.kind = statement, une ligne par élément : forfait, système téléphonique, utilisateurs complets et virtuels, numéros au-delà des inclus, numéros sans frais, équipes et widgets en plus, extensions enregistrées). Les sièges se comptent à l'anniversaire. Choisir un forfait exige la recharge automatique : sans elle,402 auto_recharge_required(pas pour l'admin de domaine). - Prorata. Un forfait plus cher, ou l'ajout du système téléphonique, s'applique tout de suite : la
différence est prélevée au prorata des jours restants de la période. Un forfait moins cher ou un
retrait attend l'anniversaire (
pendingdans la réponse). - Quotas. Les messages sortants comptent par segment (un MMS = 2), les entrants ne comptent pas.
Au-delà des messages inclus, chaque message coûte le prix de sa tranche selon le volume du mois. Les
minutes vers le Canada et les États-Unis puisent dans une réserve commune (un lot par utilisateur
complet), puis sont facturées à la minute ; les membres qui ont
billing.managesont prévenus à 80 %. Les appels internes ne sont jamais facturés ; l'international l'est au tarif du domaine. La réponse donneusage(messages et minutes de la période),seatsetnext_statement. - Limites. Au-delà de
max_users(membres qui ontmessages.readoumessages.send, à l'invitation ou au changement de profils) ou demax_teams(à la création d'une équipe) :402 plan_limitaveclimit(usersouteams),plan,upgrade(le forfait suivant, ounull) etcurrent(le nombre déjà compté : membres qui utilisent les messages et invitations en attente, ou équipes). La limite d'utilisateurs se vérifie partout où un membre reçoit les messages : invitation et son acceptation,POST /users, changement de rôle (PUT /users/<id>,PUT /organizations/<id>/members/<id>) ou de profils, modification des permissions d'un profil porté par des membres ; choisir un forfait trop petit pour l'organisation (premier abonnement ou changement, même différé à l'anniversaire) est aussi refusé, admin de domaine compris. Un domaine qui éteint les forfaits ne montre plus d'abonnement (subscription: null) et n'en facture aucun ; ils reviennent tels quels quand il les rallume. Une organisation abonnée ne paie plus les frais mensuels des numéros. - Lignes. Le système téléphonique inclut des lignes (appels en même temps : un renvoi externe en compte
deux) :
included_linespluslines_per_full_userpar utilisateur complet, avec une margeburst_linesau-dessus (par défaut autant que l'inclus, au moins 2) ; la limite des canaux de l'organisation suit (source: subscriptiondansGET /voice/limits), sauf si un administrateur en a posé une à la main. Rien ne s'achète : la pointe de la période au-delà des lignes incluses est facturée au relevé suivant (ligneextra_line, aussi dans l'estimation). - Ce qui est facturé.
GET /api/v1/billing/overview(billing.viewoubilling.manage) explique la prochaine facture de l'organisation (lignes détaillées, usage de la période, projection, dernier relevé, mois dernier) ;GET /api/v1/domains/<id>/billing-overviewen donne le résumé par organisation pour un domaine, etGET /api/v1/system/billing-overviewpar domaine pour la plateforme.
Équipe par défaut
Une organisation fonctionne sans configuration : elle reçoit une équipe par défaut
(is_default: true, nommée « Équipe principale », ou « Main team » si la langue du premier
usager est l'anglais ; le nom se change librement) à sa création (inscription, connexion
OAuth, POST /api/v1/organizations) ou, pour une organisation plus ancienne, à l'acceptation
de sa première invitation. Les numéros que l'organisation reçoit
(POST /api/v1/domains/<id>/phone-numbers/<id>/assign) sont rangés dans cette équipe, et un
membre invité sans équipe dont les profils ne donnent pas members.manage (l'ancien rôle agent)
la rejoint. Une organisation qui a plusieurs équipes et aucune
équipe par défaut garde ses numéros sans équipe : un membre qui a numbers.manage les assigne avec
POST /api/v1/phone-numbers/<id>/assign.
Webhooks entrants
Les fournisseurs livrent les SMS entrants sur /webhook/provider/<fournisseur>/<secret>, l'URL
de réception d'un compte fournisseur (un secret par compte, montré avec
le compte et régénérable). Le message est
routé vers l'équipe à laquelle le numéro de destination est assigné dans le registre des
numéros. Chaque équipe conserve son propre fil de conversation par correspondant : un numéro
réassigné à une autre équipe ouvre un nouveau fil.
Deux formes plus anciennes existent encore : /webhook/sms/<secret d'équipe> et
/webhook/domain/<fournisseur>/<secret de domaine>. Les chemins relatifs de cette seconde forme sont
ceux que rendent GET /api/v1/domains/<id>/webhook et POST /api/v1/domains/<id>/webhook/regenerate.
Les webhooks de fournisseur d'avant les comptes (/api/v1/system/provider-webhooks, super admin, sans
écran) donnent aussi une URL de la forme courante et restent acceptés.
Comptes fournisseurs SMS
Un compte fournisseur (sms_provider_accounts) porte les identifiants d'un compte chez un fournisseur
SMS et sa propre URL de réception /webhook/provider/<fournisseur>/<secret>. Il
appartient soit à la plateforme (/api/v1/system/sms-accounts, super admin : configuré une fois et
partagé par les domaines que le super admin autorise, PUT /api/v1/domains/<id>/platform-sms-accounts),
soit à un domaine (/api/v1/domains/<id>/sms-accounts, administrateur du domaine : le compte propre
d'un revendeur).
Le fournisseur se choisit par le numéro : provider_account_id d'un numéro
(PUT /api/v1/domains/<id>/phone-numbers/<id>). Un numéro sans compte utilise les réglages de
fournisseur de son domaine (/api/v1/domains/<id>/sms-providers), comme avant l'arrivée des comptes :
ce réglage hérité n'est plus proposé par l'interface, sauf pour un numéro qui s'en sert encore.
Un administrateur de domaine ne voit jamais le nom ni le fournisseur d'un compte de la plateforme : le
compte n'est pour lui qu'un identifiant et la description que le super admin a écrite, et ses numéros
affichent le fournisseur platform. Le super admin choisit les domaines d'un compte depuis le compte
(PUT /api/v1/system/sms-accounts/<id>/domains) ou depuis le domaine (PUT /api/v1/domains/<id>/platform-sms-accounts).
Les numéros s'ajoutent et se retirent dans l'inventaire de leur compte (/api/v1/system/sms-accounts/<id>/numbers,
/api/v1/domains/<id>/sms-accounts/<id>/numbers) ; un numéro affecté à une organisation ne peut pas en sortir (409).
Un numéro libre d'un compte de la plateforme change de domaine par POST /api/v1/system/sms-accounts/<id>/numbers/move.
Un domaine n'ajoute ses propres comptes que si le super admin l'y autorise (PUT /api/v1/domains/<id>/sms-account-settings).
Le journal SMS d'un domaine (GET /api/v1/domains/<id>/sms-logs) donne pour chaque message le compte de son numéro
(provider_account_id) et sa provider_description (la description d'un compte de la plateforme, jamais son nom) et
se filtre par compte (account_id) ; le filtre par type de fournisseur est réservé au super admin.
Synthèse vocale
POST /api/v1/voice/sounds/tts crée un son à partir d'un texte (text, 1000 caractères au plus, language fr, en ou es,
name facultatif) ; GET /api/v1/voice/sounds/tts dit si elle est offerte et dans quelles langues. Le son compte comme un
son téléversé (mêmes limites) et garde son texte (tts). Voix de synthèse : Piper ; la voix française vient de la
SIWIS French Speech Synthesis Database (University of Edinburgh, CC-BY 4.0), l'anglaise de LJ Speech (domaine public),
l'espagnole de davefx (CC0).
Trunks voix
Les trunks SIP suivent le même principe. Un trunk de la plateforme (/api/v1/system/voice-trunks, super admin)
est configuré une fois et partagé par les domaines autorisés (PUT /api/v1/system/voice-trunks/<id>/domains ou
PUT /api/v1/domains/<id>/platform-voice-trunks) ; un administrateur de domaine n'en voit que l'identifiant et la
description (platform_trunks de GET /api/v1/domains/<id>/voice-trunks). Un domaine n'ajoute ses propres trunks
(POST /api/v1/domains/<id>/voice-trunks) qu'avec la même autorisation que ses propres comptes SMS
(own_accounts_allowed, sinon 403 own_accounts_not_allowed). Un trunk a une direction : entrée et sortie,
entrée seulement (outbound_enabled: false) ou sortie seulement (inbound_enabled: false), jamais aucune des
deux (400) ; sans outbound_enabled, un trunk par enregistrement sort et un trunk IP ne sort pas ; un trunk qui ne sort pas peut nommer outbound_trunk_id, un autre trunk du même
propriétaire qui sort, par lequel partent ses appels et ses renvois (400 field: outbound_trunk_id sinon). Un
numéro reçoit la voix par voice_trunk_id. Celle d'un numéro de la plateforme (lié à un compte SMS ou à un trunk de la plateforme)
est réglée par le super admin (PUT /api/v1/system/phone-numbers/<id>/voice) : un admin de domaine qui
envoie voice_trunk_id pour un tel numéro reçoit 403, il ne fait que l'affecter à une organisation. Pour
les numéros de ses propres comptes, l'admin de domaine choisit un de ses propres trunks
(PUT /api/v1/domains/<id>/phone-numbers/<id>). Un numéro qui change de domaine garde un trunk de la
plateforme autorisé dans son nouveau domaine, et la voix d'un numéro sans trunk (trunk par défaut de la
plateforme).
À la réception, le message n'est livré qu'à un numéro que le compte peut servir : un compte de domaine
ne livre jamais aux numéros d'un autre domaine (404), et un numéro rattaché à un compte ne reçoit que
par celui-ci. Pour un compte de la plateforme et un domaine non autorisé, le message est livré et
journalisé tant que SMS_INBOUND_ENFORCE_PLATFORM_ACCOUNTS n'est pas activé, puis refusé.
Ces adresses ne sont soumises à aucune limite de débit : le secret de l'URL authentifie le fournisseur, et un SMS reçu n'est jamais refusé par un compteur.
Seul le webhook de livraison du fournisseur de numéros (POST /webhook/numbers/<jeton>, réglé par
l'administrateur du domaine) a une limite : 120 par minute (voir Limites de débit).
Quand la variable WEBHOOK_MIRROR_URL est définie sur le serveur, chaque livraison est
recopiée en arrière-plan vers cette URL (utilisé pour alimenter l'instance de développement).
Intégrations et webhooks sortants
Une intégration donne à une application tierce (CRM, outil de répartition, ...) un accès
aux équipes qu'une organisation choisit d'exposer, et lui pousse les événements de ces
équipes. Elle se crée dans Paramètres → Intégrations (permission integrations.manage, voir le
guide) ou par POST /api/v1/integrations avec un jeton
JWT (les routes /api/v1/integrations* refusent toute clé API), et se compose de :
- un nom (80 caractères au plus), affiché comme expéditeur des messages envoyés par l'intégration ;
- la liste des équipes exposées (vide = toutes les équipes de l'organisation, y compris celles créées plus tard) ;
- une clé API (
X-API-Key), montrée une seule fois à la création et régénérable (POST /api/v1/integrations/<id>/regenerate-key: l'ancienne cesse aussitôt de fonctionner). C'est une clé restreinte : elle ne voit que les équipes exposées, sur toutes les routes (GET /api/v1/conversationssansteam_idcompris), avec les droits du profil Utilisateur : lire et envoyer, pas administrer. La facturation, les organisations, la voix et les notifications push lui répondent 403 ; - un webhook sortant, facultatif : une URL https publique, un secret de signature (
whsec_…, lisible à tout moment, régénérable parPOST /api/v1/integrations/<id>/regenerate-secret) et la liste des événements livrés (par défaut, tous).
La clé API n'est affichée qu'une seule fois, juste après la création : copiez-la avant de fermer l'encadré.
La clé est portée par un compte de service caché : il n'apparaît ni dans les membres ni dans les
métriques par agent (ses envois comptent dans les totaux de l'équipe), et ne peut pas se connecter.
Désactiver l'intégration (enabled: false) coupe la clé et le webhook sans rien effacer. La
supprimer révoque la clé et efface le compte de service et le journal des livraisons : les messages
qu'elle a envoyés perdent la mention « via Mon CRM » et, sans sender_name, n'affichent plus
d'expéditeur. Pour arrêter une intégration en gardant l'historique, désactivez-la.
Appeler l'API avec la clé
Envoyez la clé dans X-API-Key, depuis votre serveur. X-Frontend-Host est facultatif : s'il est
présent, ce doit être l'hôte de l'application Telma de l'organisation (app.telma.ca, ou celui
de votre marque blanche). Avec l'hôte d'un autre domaine, il n'y a pas d'erreur mais aucune équipe :
les listes reviennent vides (total: 0) et toute route qui vise une équipe répond 403.
Les routes utiles sont dans les groupes SMS et conversations, Équipes, Notes de conversation et Fichiers de la référence :
| Route | Usage |
|---|---|
GET /api/v1/teams |
les équipes exposées et leur id |
GET /api/v1/teams/<team_id>/phone-numbers |
les numéros d'une équipe |
GET /api/v1/conversations |
les conversations ouvertes et fermées, de la plus récente à la plus ancienne (team_id, status, replied, q, number, unread, skip, limit) |
GET /api/v1/conversations/<id> |
une conversation, ses messages (du plus récent au plus ancien) et ses events ; la lecture la marque comme lue |
POST /api/v1/sms/send |
envoyer un SMS ou un MMS |
POST /api/v1/upload |
téléverser la pièce jointe d'un MMS |
POST /api/v1/conversations/<id>/close, .../reopen |
fermer, rouvrir |
PATCH /api/v1/conversations/<id> |
nommer le contact de la conversation (contact_name, null pour effacer) |
GET, POST /api/v1/conversations/<id>/notes, PUT, DELETE .../notes/<note_id> |
notes de conversation |
GET /api/v1/media-proxy |
médias des MMS entrants (le proxied_url des événements) |
Pagination. Les listes acceptent skip (0 par défaut, une valeur négative compte pour 0,
2147483647 au plus) et limit (50 par défaut, 100 au plus : une valeur plus grande est ramenée à
100, 0 ou moins à 1). Un skip ou un limit qui n'est pas un entier, ou un skip supérieur à
2147483647, donne 400 validation_error avec field.
GET /api/v1/conversations rappelle skip et limit et donne dans total le nombre de
conversations, GET /api/v1/teams de même avec le nombre d'équipes,
GET /api/v1/conversations/<id> le nombre de messages, GET .../notes le nombre de notes. Les
équipes et les notes sont toutes renvoyées quand limit est absent (GET /api/v1/teams rappelle
alors "limit": null).
Conversations. Une conversation est ouverte ou fermée : depuis le 2026-10-14, fermer, c'est
archiver (il n'y a plus d'état archived distinct ; POST .../archive reste accepté et ferme la
conversation, et les anciennes archivées sont devenues fermées). Une conversation fermée porte la date
de sa fermeture, closed_at, et un message du contact la rouvre. GET /api/v1/conversations
n'accepte que status=open ou status=closed (vide = les deux) ; status=archived donne 400. Les
conversations fermées d'une équipe, rangées par jour de fermeture, sont aussi dans
GET /api/v1/teams/<team_id>/archives (et .../archives/dates), qui demandent messages.archive (profils
Admin et Manager), donc refusées à une clé restreinte. replied=1 ne garde que les
conversations où le contact a écrit au moins une fois, replied=0 celles qui attendent sa première
réponse.
Envoyer. Une clé d'intégration n'a pas d'équipe à elle : POST /api/v1/sms/send exige
team_id ou from_number (le numéro désigne alors son équipe), sinon 403. to est obligatoire, et
body aussi sauf s'il y a des pièces jointes. sender_name (la personne qui envoie depuis votre
application) et external_sender (son identifiant chez vous), 120 caractères au plus, donnent au
message sent_by_name = cette personne et sent_via = le nom de l'intégration : Telma affiche
« Julie Tremblay via Mon CRM » au-dessus de la bulle. Sans sender_name, l'expéditeur affiché est
l'intégration. Un refus du fournisseur répond 500 send_failed ; le message reste dans la
conversation avec le statut failed, et le renvoyer en crée un second.
Un message envoyé depuis le CRM au nom de Julie Tremblay (« Julie Tremblay via Mon CRM ») et une note de conversation écrite par la même intégration, dans le fil et dans le panneau Notes.
Pièces jointes (MMS). Téléversez d'abord le fichier : POST /api/v1/upload en
multipart/form-data, champ file, 10 Mo au plus, extension jpg, jpeg, png, gif, webp, mp4, mov,
mp3, wav ou m4a. La réponse 201 donne url, content_type et filename. Passez url telle quelle
dans media_urls (une liste de chaînes ou d'objets {"url": …}) : toute autre URL est refusée
(400 validation_error), et le type enregistré est celui du fichier téléversé. Le fichier est servi
sans authentification (le fournisseur doit pouvoir le télécharger), à une adresse impossible à
deviner.
Notes de conversation. Ce sont des commentaires de l'équipe, jamais envoyés au contact (body
de 4000 caractères au plus, en création comme en modification). Une note se place dans le fil à son
moment (anchor_at) ou juste après le message qu'elle commente (message_id). Avec une clé
d'intégration, author_name porte le nom de la personne qui écrit dans l'application tierce (sinon
le nom de l'intégration) et external_author son identifiant chez vous. La clé ne modifie ou ne
supprime que les notes écrites par elle ; vérifiez external_author de votre côté avant de laisser
un usager modifier une note.
Fermetures et réouvertures. La lecture d'une conversation renvoie aussi events : chaque
fermeture ou réouverture, dans l'ordre du fil (type closed/reopened, cause agent = un agent
ou l'intégration, inbound = message du contact, outbound = envoi dans une conversation archivée, avant le 2026-10-14 (un envoi dans une conversation fermée la laisse fermée),
actor_name, via, message_id du message entrant qui l'a rouverte, anchor_at). Avec une clé
d'intégration, POST .../close et .../reopen acceptent actor_name (la personne qui agit dans
votre application, 120 caractères au plus).
Exemple : démarrage rapide en curl
Ces commandes sont rejouées telles quelles par les tests de Telma. Les réponses sont réelles,
abrégées (…), avec des identifiants fictifs. Un client a écrit au numéro de l'équipe « Ventes »,
exposée à l'intégration « Mon CRM ».
export TEXTOS_API=https://api.textos.ca
export TEXTOS_KEY=txts_exemple0123456789abcdef # montrée une seule fois, à la création de l'intégration
1. Les équipes exposées et leurs numéros
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": "Ventes", "organization_id": "6710a0000000000000000040",
"phone_number": "+15145550100", "timezone": "America/Montreal", …}],
"total": 1, "skip": 0, "limit": 50}
{"phone_numbers": [{"number": "+15145550100", "label": "Ventes", "capabilities": ["SMS", "MMS"]}]}
2. Les conversations ouvertes où le client a écrit, puis la lecture de l'une d'elles
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": "Ventes",
"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": "Bonjour, mon conteneur est-il livré ?", "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"]}
La lecture marque la conversation comme lue (unread_count revient à 0). Un message entrant garde le
format du fournisseur ("from_number": "15145550123") : identifiez le contact par
conversation.phone_number.
3. Envoyer un SMS, par team_id ou par 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": "Oui, livré ce matin à 9 h."}'
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": "Merci de votre confiance !"}'
Réponse 201 (la seconde a la même forme) :
{"message": {"id": "6710a0000000000000000010", "conversation_id": "6710a0000000000000000002", "direction": "outbound",
"from_number": "+15145550100", "to_number": "+15145550123", "body": "Oui, livré ce matin à 9 h.",
"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 est le compte de service de l'intégration.
4. Envoyer au nom d'une personne de votre 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": "Je vous rappelle dans 5 minutes.", "sender_name": "Julie Tremblay", "external_sender": "user-42"}'
{"message": {"id": "6710a0000000000000000012", "body": "Je vous rappelle dans 5 minutes.", "status": "sent",
"sent_by_name": "Julie Tremblay", "sent_via": "Mon CRM", "external_sender": "user-42", …},
"conversation": {…}}
5. Envoyer un MMS : téléverser, puis envoyer l'URL reçue
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": "Photo de la livraison", "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": "Photo de la livraison", "status": "sent",
"media_urls": [{"url": "https://api.textos.ca/api/v1/uploads/9f1c2e3d4b5a69788766554433221100.jpg",
"content_type": "image/jpeg"}], …},
"conversation": {…}}
6. Ajouter une note (jamais envoyée au contact)
curl -s "$TEXTOS_API/api/v1/conversations/6710a0000000000000000002/notes" -H "X-API-Key: $TEXTOS_KEY" -H "Content-Type: application/json" \
-d '{"body": "Client rappelé, livraison confirmée.", "author_name": "Julie Tremblay", "external_author": "user-42"}'
Réponse 201, complète :
{"note": {"id": "6710a0000000000000000020", "conversation_id": "6710a0000000000000000002",
"team_id": "6710a0000000000000000001", "message_id": null, "anchor_at": "2026-10-09T18:43:39.930821",
"body": "Client rappelé, livraison confirmée.", "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. Fermer, puis rouvrir, au nom de la personne
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", …}}
La lecture suivante de la conversation donne ces deux changements dans 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", …}]
Les mêmes appels avec X-Frontend-Host: app.telma.ca (l'hôte de l'organisation) donnent les mêmes
réponses ; avec l'hôte d'un autre domaine, la liste revient vide
({"conversations": [], "total": 0, "skip": 0, "limit": 50}) et l'envoi répond 403.
Événements livrés
| Événement | Quand | Contenu |
|---|---|---|
message.received |
un SMS/MMS entrant est enregistré dans une équipe exposée | conversation, message |
message.sent |
un message sortant est envoyé : par un agent, par l'intégration, par le widget de site web ou automatiquement (message de bienvenue, réponse hors heures, livrés juste après le message entrant auquel ils répondent). Un envoi refusé (500 send_failed) n'est pas livré |
conversation, message |
message.status |
un fournisseur qui envoie des accusés de livraison signale un statut (pending, sent, delivered, failed). Les fournisseurs actuels de la plateforme n'en envoient pas : pour leurs numéros, cet événement n'arrive jamais et le statut reste celui de l'envoi |
message, conversation |
conversation.updated |
une conversation est lue (si elle avait des messages non lus), fermée ou rouverte, que ce soit par un agent, par l'intégration, par un message du contact (ou, avant le 2026-10-14, par un envoi dans une conversation archivée) ; ou son contact est renommé (PATCH /api/v1/conversations/<id>, par un agent ou par l'intégration ; le même nom ne livre rien). La suppression d'une conversation n'est pas signalée |
conversation, change (read, closed, reopened, renamed ; archived n'est plus envoyé depuis le 2026-10-14 : archiver ferme), cause (agent, inbound, outbound ; absent pour read et renamed), message_id (réouverture par un message du contact), contact_name (renamed seulement : le nouveau nom, ou null quand il est effacé) |
conversation.note |
une note de conversation est ajoutée, modifiée ou supprimée | note, change (created, updated, deleted), conversation_id |
Chaque livraison est un POST JSON sur l'URL du webhook. L'enveloppe porte id (l'identifiant de
la livraison), event, created_at, organization_id et team (id, name), puis les champs de
l'événement. Un MMS reçu, tel que livré :
{
"event": "message.received",
"created_at": "2026-10-09T18:43:39.431526Z",
"organization_id": "6710a0000000000000000040",
"team": {"id": "6710a0000000000000000001", "name": "Ventes"},
"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": "Voici la 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": "Voici la 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.fournisseur.example/media/7f3e2a.jpg",
"content_type": "image/jpeg",
"proxied_url": "https://api.textos.ca/api/v1/media-proxy?url=https%3A%2F%2Fmms.fournisseur.example%2Fmedia%2F7f3e2a.jpg&content_type=image%2Fjpeg"
}
],
"created_at": "2026-10-09T18:43:39.429512",
"delivered_at": null
},
"id": "6711b0000000000000000002"
}
Un message reçu hors des horaires d'ouverture de l'équipe porte queued: true si l'équipe met ces
messages en file d'attente, ou rejected: true si elle les rejette : le message est conservé et
affiché, mais l'équipe ne reçoit pas de notification push. Les deux valent false sinon.
conversation et message ont le format de l'API (GET /api/v1/conversations/<id>), avec trois
ajouts ou écarts. conversation.last_message ({id, body, direction, status, created_at, has_media}) est
l'aperçu du dernier message de la conversation, celui qu'elle compte au moment de la livraison, comme dans
l'API :
- un message envoyé par une intégration porte en plus
integration_id: votre application reconnaît ainsi ses propres envois ; - chaque média porte
proxied_url. Pour un fichier téléversé sur Telma (POST /api/v1/upload), c'est son adresse même ; pour un média gardé par le fournisseur, c'est le relais public de Telma (GET /api/v1/media-proxy) : il ne relaie que les médias des messages (l'URL doit être exactement leurld'un média d'un message, sinon 404not_found), n'accepte que les noms d'hôte des fournisseurs SMS (en http ou https, ports 80 et 443, sans identifiants ni adresse IP, et seulement s'ils pointent vers des adresses publiques), 5 Mo au plus, en 90 s au plus, sans suivre de redirection ; il convertit l'audio AMR en MP3 et la vidéo 3GPP en MP4, affiche les images, l'audio, la vidéo, les PDF et le texte brut avec leur type, et donne tout autre fichier en téléchargement (application/octet-stream, nommémedia.<extension>d'après son type, par exemplemedia.vcfpour une carte de contact) ; un média récupéré est gardé jusqu'à 10 minutes : la même URL redemandée pendant ce temps est servie sans nouvelle récupération, convertie ou non, et accepte les requêtesRange(lecture d'une vidéo par morceaux) ; une URL refusée donne 400validation_error, un média que le fournisseur ne rend pas 502fetch_failed, et quand trop de médias sont déjà en cours de récupération ou de conversion (une requête attend son tour 15 s au plus, derrière 100 autres au plus), le relais répond 503busy: réessayez quelques secondes plus tard ; - dans
message.status,sent_viavautnulletsent_by_namene garde que lesender_namedonné à l'envoi (nullpour un message d'agent).
message.provider vaut null (le fournisseur n'est montré qu'à la plateforme), et provider_message_id
est l'identifiant que le fournisseur a donné au message. Ce qu'un numéro peut envoyer se lit dans
capabilities (["SMS"] ou ["SMS", "MMS"]) de GET /api/v1/teams/<id>/phone-numbers et dans
messaging_capabilities des numéros ; leur provider vaut null pour une organisation.
message.from_number et message.to_number d'un message entrant gardent le format du fournisseur
(souvent sans +) : identifiez le contact par conversation.phone_number et le numéro de l'équipe
par conversation.team_phone_number, tous deux en E.164.
Toutes les dates sont en UTC. created_at de l'enveloppe porte le suffixe Z ; celles de
conversation et message (comme dans l'API) n'en ont pas : ajoutez Z avant de les interpréter
(en JavaScript, new Date("2026-10-09T18:43:39.429000") la lirait en heure locale).
Contact renommé (conversation.updated, "change": "renamed", abrégé) : contact_name donne le nouveau
nom (null quand il est effacé), le même que conversation.contact_name ; pas de cause. Le nom
appartient à la conversation : une autre conversation avec le même numéro (autre numéro d'équipe,
autre équipe) garde le sien.
{
"event": "conversation.updated",
"created_at": "2026-10-14T15:02:11.204518Z",
"organization_id": "6710a0000000000000000040",
"team": {"id": "6710a0000000000000000001", "name": "Ventes"},
"conversation": {"id": "6710a0000000000000000002", "phone_number": "+15145550123", "contact_name": "Marie Tremblay", "status": "open", "…": "…"},
"change": "renamed",
"contact_name": "Marie Tremblay",
"id": "6711b000000000000000000c"
}
En-têtes de la requête :
| En-tête | Contenu |
|---|---|
X-Textos-Event |
le nom de l'événement |
X-Textos-Delivery |
l'identifiant de la livraison (le même qu'id, répété à chaque réessai) |
X-Textos-Timestamp |
l'heure de l'envoi, en secondes Unix |
X-Textos-Signature |
sha256= suivi du HMAC-SHA256, en hexadécimal, de "<timestamp>.<corps brut>" avec le secret du webhook |
User-Agent |
Textos-Webhooks/1.0 |
Exemple : charges utiles réelles
Corps reçus pendant une exécution du démarrage rapide (identifiants fictifs). Envoi par
l'intégration au nom d'une personne (message.sent) :
Charge utile message.sent complète
{
"event": "message.sent",
"created_at": "2026-10-09T18:43:39.882741Z",
"organization_id": "6710a0000000000000000040",
"team": {"id": "6710a0000000000000000001", "name": "Ventes"},
"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": "Je vous rappelle dans 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": "Je vous rappelle dans 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"
}
Conversation fermée, puis rouverte par un nouveau message du contact (conversation.updated) :
cause vaut inbound et message_id désigne ce message, livré ensuite par message.received.
Une fermeture (y compris par l'ancien POST .../archive) ou une réouverture par un agent ou par l'intégration a la même forme avec
"cause": "agent" ; read n'a pas de cause.
Charge utile conversation.updated complète
{
"event": "conversation.updated",
"created_at": "2026-10-09T18:43:40.112093Z",
"organization_id": "6710a0000000000000000040",
"team": {"id": "6710a0000000000000000001", "name": "Ventes"},
"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": "Encore une 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"
}
Note ajoutée (conversation.note) : pas d'objet conversation, seulement conversation_id. Pour
"change": "deleted", note est la note telle qu'elle était avant la suppression.
Charge utile conversation.note complète
{
"event": "conversation.note",
"created_at": "2026-10-09T18:43:39.931077Z",
"organization_id": "6710a0000000000000000040",
"team": {"id": "6710a0000000000000000001", "name": "Ventes"},
"note": {
"id": "6710a0000000000000000020",
"conversation_id": "6710a0000000000000000002",
"team_id": "6710a0000000000000000001",
"message_id": null,
"anchor_at": "2026-10-09T18:43:39.930821",
"body": "Client rappelé, livraison confirmée.",
"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 (bouton Tester le webhook, POST /api/v1/integrations/<id>/test), avec
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"}
}
Vérifier la signature
Calculez le HMAC sur le corps tel que reçu (les octets, avant tout décodage JSON : un JSON
réencodé ne donne pas la même signature), comparez en temps constant et rejetez les horodatages trop
anciens (cinq minutes ici). Ces fonctions renvoient False sur un en-tête absent ou mal formé, sans
lever d'exception :
import hashlib
import hmac
import time
def textos_signature_is_valid(secret, headers, raw_body, tolerance=300):
# headers : request.headers (Flask, Django, FastAPI : noms insensibles à la casse)
# raw_body : les octets reçus (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 : noms en minuscules)
// rawBody : le Buffer reçu (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);
}
Obtenir le corps brut selon le cadre :
| Cadre | Corps brut | En-têtes |
|---|---|---|
| Flask | request.get_data() |
request.headers |
| Django | request.body |
request.headers |
| Express | express.raw({ type: "application/json" }) sur cette route, puis req.body (un app.use(express.json()) global donne un objet et fait échouer la vérification) |
req.headers |
| Fetch (Next.js App Router, Workers, Deno, Bun) | Buffer.from(await request.arrayBuffer()) |
Object.fromEntries(request.headers) |
Pendant un changement de secret, votre récepteur peut accepter l'ancien et le nouveau (la livraison est valide si l'un des deux la vérifie).
Exemple : récepteur de webhook complet
Les deux récepteurs ci-dessous font la même chose : ils vérifient la signature sur le corps brut,
répondent 204 tout de suite, ignorent un réessai d'une livraison déjà acceptée (même
X-Textos-Delivery), traitent l'événement en arrière-plan et ignorent les envois faits par leur
propre intégration (message.integration_id). Le ping reçoit 204 sans passer par le
dédoublonnage, puisque son X-Textos-Delivery vaut toujours test. Les types d'événement inconnus
sont ignorés : de nouveaux types peuvent s'ajouter. Ils sont testés en vrai contre les livraisons
signées de Telma.
TEXTOS_WEBHOOK_SECRET est le secret du webhook (whsec_…, Paramètres → Intégrations) et
TEXTOS_INTEGRATION_ID l'identifiant de l'intégration (integration.id de la réponse à sa création,
ou du ping). L'ensemble seen et la file sont en mémoire : ils ne valent que pour un seul
processus. En production, notez les identifiants de livraison dans votre base (contrainte d'unicité,
sept jours suffisent) et confiez le traitement à votre file de tâches.
Python (Flask) — pip install flask, puis
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") # pour reconnaître vos propres envois
app = Flask(__name__)
log = logging.getLogger("textos")
jobs = queue.Queue()
seen = set() # livraisons acceptées ; en production : une table avec contrainte d'unicité
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() # les octets reçus, avant tout décodage JSON
if not textos_signature_is_valid(SECRET, request.headers, raw):
abort(401)
event = json.loads(raw)
if event["event"] == "ping": # bouton « Tester » : X-Textos-Delivery vaut toujours "test"
return "", 204
delivery_id = request.headers["X-Textos-Delivery"]
with seen_lock:
if delivery_id in seen: # réessai d'une livraison déjà acceptée
return "", 204
seen.add(delivery_id)
jobs.put(event)
return "", 204 # répondre vite : Telma n'attend pas la réponse plus de 10 s
def handle(event):
kind = event["event"]
conversation = event.get("conversation") or {}
message = event.get("message") or {}
if kind == "message.received":
# le contact : conversation.phone_number (E.164) ; message.from_number garde le format du fournisseur
media = message.get("media_urls") or [] # pièces jointes : media[i]["proxied_url"]
log.info("SMS de %s : %s (%d pièce(s) jointe(s))", conversation["phone_number"], message["body"], len(media))
elif kind == "message.sent":
if message.get("integration_id") == INTEGRATION_ID:
return # envoyé par votre application : déjà connu
log.info("Réponse de %s à %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"])
# autres événements : ignorés
def worker():
while True:
event = jobs.get()
try:
handle(event)
except Exception:
log.exception("Événement %s non traité", event.get("id"))
logging.basicConfig(level=logging.INFO)
threading.Thread(target=worker, daemon=True).start()
Node (Express 4 ou 5) — npm install express, puis
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; // pour reconnaître vos propres envois
const seen = new Set(); // livraisons acceptées ; en production : une table avec contrainte d'unicité
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();
// Corps brut sur cette route : un app.use(express.json()) global ferait échouer la vérification
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); // bouton « Tester » : X-Textos-Delivery vaut toujours "test"
const deliveryId = req.headers["x-textos-delivery"];
if (seen.has(deliveryId)) return res.sendStatus(204); // réessai d'une livraison déjà acceptée
seen.add(deliveryId);
res.sendStatus(204); // répondre vite : Telma n'attend pas la réponse plus de 10 s
setImmediate(() => {
try {
handle(event);
} catch (err) {
console.error(`Événement ${event.id} non traité`, err);
}
});
});
function handle(event) {
const conversation = event.conversation || {};
const message = event.message || {};
switch (event.event) {
case "message.received": {
// le contact : conversation.phone_number (E.164) ; message.from_number garde le format du fournisseur
const media = message.media_urls || []; // pièces jointes : media[i].proxied_url
console.log(`SMS de ${conversation.phone_number} : ${message.body} (${media.length} pièce(s) jointe(s))`);
break;
}
case "message.sent":
if (message.integration_id === INTEGRATION_ID) return; // envoyé par votre application : déjà connu
console.log(`Réponse de ${message.sent_by_name} à ${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: // autres événements : ignorés
}
}
app.listen(Number(process.env.PORT) || 8000);
Pour essayer sans Telma, envoyez-vous une livraison signée (date +%s donne l'horodatage, le corps
est signé tel quel) :
SECRET=whsec_…; TS=$(date +%s); BODY='{"id":"essai-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: essai-1" \
-H "X-Textos-Timestamp: $TS" -H "X-Textos-Signature: sha256=$SIG" -d "$BODY"
# → HTTP/1.1 204 ; le même appel avec un autre secret ou un corps modifié → 401
Telma, lui, n'appelle qu'une adresse https publique (voir ci-dessous) : pour recevoir de vraies livraisons, publiez le récepteur derrière https.
Livraison, réessais et pause
L'URL du webhook doit être en https sur le port 443 (le port par défaut de https), publique, de
500 caractères au plus, sans identifiants (user:password@) ni espace. Son nom d'hôte est résolu, et
chaque adresse obtenue doit être publique : les adresses privées, de bouclage, lien-local, réservées,
multicast, non spécifiées ou non globales (dont 100.64.0.0/10) sont refusées, en IPv4 comme en IPv6
(y compris une IPv4 portée par une IPv6), de même qu'un nom qui ne se résout pas. L'adresse publique
du serveur qui héberge Telma n'est pas refusée : une application hébergée sur ce même serveur reçoit
ses webhooks. L'exploitant de la plateforme peut en outre exclure des réseaux (variable
WEBHOOK_DENY_NETWORKS du serveur, vide par défaut). Tout refus donne
400 {"error": "validation_error", "message": "webhook_url must be a public https URL"}, quelle
qu'en soit la cause. L'URL est vérifiée quand elle est enregistrée avec une nouvelle valeur, et de
nouveau avant chaque appel : si elle ne passe plus, rien n'est envoyé et l'appel échoue
(address not allowed, ou connection error si le nom ne se résout plus). L'URL déjà enregistrée,
renvoyée telle quelle (l'interface renvoie tout le formulaire), n'est pas vérifiée de nouveau : une
intégration se renomme ou se désactive même quand le nom de son récepteur ne se résout plus.
"" ou null retire le webhook.
Le récepteur doit répondre 2xx : Telma se connecte en 5 s au plus, attend la réponse 10 s au
plus et abandonne l'appel après 15 s en tout. Une redirection (3xx) n'est jamais suivie et compte
comme un échec. La ligne d'état et les en-têtes de la réponse sont lus jusqu'à 8 Ko par ligne et
32 Ko en tout : au-delà, l'appel échoue (connection error). Le corps de la réponse est ignoré :
Telma en lit au plus 1 Ko, sans le conserver ni l'afficher. Un échec est noté uniquement sous la
forme HTTP <code>, timeout, connection error, redirect not followed, address not allowed
ou not sent (ci-dessous).
Telma fait au plus 16 appels de webhook à la fois, dont 3 au plus pour une même organisation ; les
organisations qui attendent prennent à tour de rôle les places qui se libèrent, et les appels d'une
même organisation partent dans l'ordre. Un appel qui attend son tour n'est jamais compté contre le
récepteur : X-Textos-Timestamp et la signature sont calculés au moment de l'envoi, après l'attente.
Une livraison attend son tour 10 minutes au plus ; si elle ne l'a pas eu, ou si trop d'appels
attendent déjà (2000 en tout, 100 pour une même organisation), rien n'est envoyé : la livraison est
notée not sent, ce qui ne compte ni comme une tentative, ni dans les échecs d'affilée, ni pour la
pause, et elle reste en cours (completed_at à null). Telma la reprend de lui-même une à deux
minutes plus tard (et de nouveau tant qu'elle n'a pas son tour), avec les tentatives qui lui
restent, vers l'URL et avec le secret de ce moment-là : les plus anciennes d'abord, 20 par minute au
plus pour une même intégration, et pendant une heure après l'événement. Passé ce délai, ou si
l'intégration ne prend plus cet événement (intégration désactivée, webhook retiré ou en pause,
événement ou équipe décochés), la livraison se termine avec l'erreur not sent, sans appel.
Chaque livraison fait au plus trois tentatives (la première, puis après 5 s et après 30 s), avec le
même X-Textos-Delivery : le récepteur doit être idempotent sur cet identifiant. Les livraisons sont
asynchrones et peuvent arriver dans le désordre : la date created_at et les champs de l'objet font
foi.
Après 25 livraisons échouées d'affilée, le webhook est mis en pause (la dernière erreur est
visible dans l'interface et dans webhook.last_error de GET /api/v1/integrations/<id>) ; la clé
API continue de fonctionner. Il reprend en modifiant l'intégration avec webhook_paused: false, ou
en changeant l'URL (le compteur d'échecs repart de zéro). Les événements survenus pendant la pause ne
sont jamais livrés : rattrapez-les par l'API (GET /api/v1/conversations).
Le webhook : secret de signature masqué, état, résultat de « Tester le webhook » et journal des dernières livraisons.
POST /api/v1/integrations/<id>/test envoie un événement ping signé, sans réessai, même si
l'intégration est désactivée ou son webhook en pause, et répond par exemple
{"success": true, "status_code": 204, "error": null} ou
{"success": false, "status_code": 500, "error": "HTTP 500"}. Le ping a une forme réduite : id
et X-Textos-Delivery valent toujours test (ne le dédoublonnez pas), integration (id, name)
remplace team, et il n'a ni conversation ni message. Il n'apparaît pas dans les livraisons et
ne compte pas dans l'état du webhook. Le récepteur doit répondre 2xx au ping et ignorer les
événements qu'il ne connaît pas. Le ping attend son tour 5 s au plus (sinon
{"success": false, "status_code": null, "error": "not sent"}, rien n'est envoyé), et la route
accepte 10 tests par minute et par organisation, toutes ses intégrations confondues (429
rate_limited au-delà).
GET /api/v1/integrations/<id>/deliveries liste les livraisons les plus récentes avec leur charge
utile : 50 par défaut, limit de 1 à 200 (un limit qui n'est pas un entier donne 400), sans
pagination. Les livraisons sont conservées sept jours.
Sécurité
- Gardez la clé côté serveur. Elle lit les conversations des équipes exposées et envoie des SMS facturés à l'organisation : ne la mettez jamais dans un navigateur, une application mobile ou un dépôt de code ; lisez-la dans une variable d'environnement ou un gestionnaire de secrets. Une page web ou une application mobile passe par votre propre serveur.
- Moindre privilège. N'exposez que les équipes dont l'application a besoin, et créez une intégration par application : chacune a sa clé, son secret et son journal de livraisons.
- Ce que la clé ne peut pas faire. Elle n'a que les droits du profil Utilisateur sur les équipes exposées : elle ne
touche à aucune autre équipe, ne liste pas les conversations fermées d'une équipe par jour
(
.../archives), ne lit ni la facturation, ni les membres, ni les paramètres de l'organisation, et ne peut créer ni clé ni intégration (403). Les conversations fermées d'une équipe exposée restent listées avecstatus=closedet lisibles par leur identifiant. - https obligatoire. Les événements contiennent des numéros de téléphone et le texte des messages : l'URL du webhook doit être une adresse https publique.
- Vérifiez la signature sur le corps brut de chaque livraison, avec une comparaison en temps constant, et refusez les horodatages de plus de cinq minutes (Vérifier la signature). Répondez 401 sans rien traiter si elle ne correspond pas.
- Idempotence. Notez les
X-Textos-Deliverydéjà traités (sept jours suffisent) : un réessai rejoue la même livraison. - Rotation. Régénérer la clé (
POST /api/v1/integrations/<id>/regenerate-key) remplace la clé : l'ancienne reçoit aussitôt 401. Régénérer le secret (POST /api/v1/integrations/<id>/regenerate-secret) remplace le secret : les livraisons suivantes sont signées avec le nouveau, que votre récepteur doit déjà accepter. Faites-le dès qu'une clé ou un secret a pu fuir. - Médias. Les fichiers téléversés et les
proxied_urlsont publics, à une adresse impossible à deviner : ne les republiez pas.
Exemple : les erreurs à gérer
Réponses réelles de l'API pour une clé d'intégration (le texte de message est en français sans
Accept-Language: en ; testez error et le code HTTP, pas message) :
| Situation | HTTP | Corps |
|---|---|---|
| Clé inconnue, remplacée par Régénérer la clé, intégration désactivée ou supprimée | 401 | {"error": "unauthorized", "message": "Invalid API key"} |
team_id ou from_number d'une équipe non exposée, envoi sans team_id ni from_number, ou X-Frontend-Host d'un autre domaine |
403 | {"error": "forbidden", "message": "Accès refusé"} ("Access denied" avec Accept-Language: en) |
| Route fermée aux clés restreintes (facturation, organisation, archives, intégrations…) | 403 | {"error": "forbidden", "message": "API key not allowed on this route"} |
from_number qui n'est pas un numéro de l'équipe team_id |
400 | {"error": "number_not_in_team", "message": "Le numéro +15145550200 n'est pas ou plus assigné à cette équipe"} |
media_urls qui ne vient pas de POST /api/v1/upload |
400 | {"error": "validation_error", "message": "media_urls must point to uploaded attachments"} |
Ni body ni media_urls |
400 | {"error": "validation_error", "message": "Contenu du message requis"} |
| Corps qui n'est pas un objet JSON | 400 | {"error": "validation_error", "message": "The request body must be a JSON object"} |
status=archived (ou autre que open, closed) dans 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"} |
| Crédit insuffisant (rien n'est envoyé ni enregistré) | 402 | {"error": "insufficient_balance", "message": "billing.insufficient_balance", "estimated_cost": 0.04, "current_balance": 0.0} |
Refus du fournisseur (message et error_code viennent de lui) |
500 | {"error": "send_failed", "message": "Destination refused by carrier", "error_code": "21610", "message_id": "6710a0000000000000000015"} |
Plus de 600 POST /api/v1/sms/send dans l'heure pour la clé, refus compris |
429 | {"error": "rate_limited", "message": "Rate limit exceeded (600 per 1 hour), retry in 1312 s", "retry_after": 1312} |
Sur un 500 send_failed, le message existe déjà dans la conversation avec le statut failed
(message_id) : un nouvel essai crée un second message, ne réessayez pas sans décision. Un 429 est
rendu avant tout traitement : rien n'a été envoyé, réessayez après Retry-After secondes (voir
Limites de débit, avec un client Python et Node qui le fait).
En-têtes de requête
| En-tête | Rôle |
|---|---|
Authorization: Bearer <jeton> |
le jeton d'accès (JWT), voir Jeton JWT |
X-API-Key |
la clé API, à la place du jeton, voir Clé API |
Accept-Language |
fr ou en : la langue de message dans les réponses (français par défaut) |
X-Frontend-Host |
le domaine whitelabel visé (à défaut, l'en-tête Host), voir Multi-domaine |
X-Admin-Organization |
super_admin et domain_admin : l'organisation administrée, voir Contexte d'administration |
X-Organization-Fallback: none |
GET /api/v1/organizations/current : ne pas retomber sur une autre organisation du domaine quand l'organisation courante ne convient pas ; la réponse est alors 404 not_found |
Idempotency-Key |
POST /api/v1/billing/recharge : 8 à 128 caractères (ou idempotency_key dans le corps) ; une même clé ne débite jamais deux fois et rend la première réponse, avec replayed: true |
X-MFA-Token |
le mfa_token d'une connexion qui doit d'abord ajouter un second facteur, sur les routes d'ajout d'un facteur (voir Sécurité de la connexion) |
X-Client-App: ios ou android |
l'application mobile, qui n'envoie pas d'Origin : les clés d'accès sont alors celles du site de X-Frontend-Host |
X-Request-ID |
l'identifiant de la requête (64 caractères au plus) : absent, le serveur en génère un ; toute réponse le porte, à citer dans une demande de support |
Erreurs
Toutes les erreurs sont en JSON, avec au moins error (un code stable, à tester) et message (un
texte lisible, traduit selon Accept-Language, qui peut changer) :
Les webhooks entrants des fournisseurs (/webhook/sms, /webhook/domain et
/webhook/provider) font exception : ils répondent {"error": "<texte>"}, un texte libre sans message
(par exemple 404 {"error": "Invalid webhook token"}), et il ne faut pas tester ce texte.
Certaines ajoutent des champs : field (le paramètre en cause d'un 400), retry_after (429),
estimated_cost et current_balance (402), error_code et message_id (500 send_failed).
| Code HTTP | error |
Sens |
|---|---|---|
| 400 | validation_error |
données invalides ou manquantes |
| 400 | configuration_error |
l'équipe n'a pas de numéro, ou son fournisseur SMS n'est pas configuré ou activé |
| 400 | number_not_in_team |
from_number n'est pas (ou plus) un numéro de l'équipe |
| 401 | unauthorized |
authentification requise, jeton invalide ou expiré, clé API inconnue, révoquée ou d'une intégration désactivée |
| 402 | insufficient_balance |
crédit insuffisant pour envoyer ; rien n'est envoyé |
| 402 | plan_limit |
le forfait de l'organisation ne permet pas un utilisateur ou une équipe de plus (limit, plan, upgrade, current ; voir Forfaits) |
| 402 | auto_recharge_required |
choisir un forfait exige la recharge automatique par carte |
| 402 | account_suspended |
solde resté négatif après le délai de grâce : envoi, SMS système et appels refusés jusqu'à ce que le solde revienne à zéro ou plus (balance_state, release_at, balance dans la réponse) |
| 403 | forbidden |
droits insuffisants (permission, équipe ou domaine) ; API key not allowed on this route pour une route fermée à la clé |
| 403 | recording_skip_not_allowed |
l'organisation ne permet pas de sauter l'enregistrement imposé du prochain appel (voir Voix) |
| 403 | outbound_suspended |
les appels sortants de l'organisation sont suspendus (code d'appel refusé ; voir Voix) |
| 403 | number_disabled |
le numéro qui enverrait est coupé (en pause, inactif ou sans SMS) |
| 403 | voice_not_enabled, capability_required |
l'organisation n'a pas la capacité voxos, ou celle que nomme capability (voir Capacités) |
| 403 | impersonation_forbidden |
opération refusée à une session ouverte par un administrateur (voir Session ouverte par un administrateur) |
| 404 | not_found |
ressource inconnue, ou aucune organisation dans ce domaine |
| 405 | method_not_allowed |
méthode HTTP non prise en charge par cette route (l'en-tête Allow donne les méthodes acceptées) |
| 409 | mfa_required |
retrait du dernier second facteur d'un compte qui doit en avoir un (les autres codes du second facteur sont dans Sécurité de la connexion) |
| 409 | profile_in_use, last_admin |
profil encore porté par des membres ; changement qui ne laisserait aucun membre avec members.admin |
| 409 | conflict |
doublon (déjà membre de l'organisation, administrateur de domaine déjà invité...) ; une invitation d'organisation déjà en attente donne 400 validation_error |
| 413 | request_entity_too_large |
corps de plus de 32 Mo (POST /api/v1/upload accepte 10 Mo au plus et refuse au-delà en 400 validation_error, pas en 413) |
| 429 | rate_limited |
limite de débit atteinte (voir Limites de débit) |
| 500 | send_failed |
le fournisseur SMS a refusé ou échoué |
| 500 | internal_error |
erreur inattendue du serveur |
| 404 | not_found |
GET /api/v1/media-proxy : aucun message ne porte ce média (passez url tel quel, depuis media_urls[].url) |
| 502 | fetch_failed |
GET /api/v1/media-proxy : le fournisseur n'a pas rendu le média à temps, ou il dépasse 5 Mo |
| 503 | busy |
GET /api/v1/media-proxy : trop de médias en cours de récupération ou de conversion ; réessayez quelques secondes plus tard |
Les routes de facturation (/api/v1/billing/*) suivent la même forme : no_organization (400,
l'usager n'a pas d'organisation courante), validation_error (400, avec field : montant,
seuil, date ISO 8601 invalide), configuration_error (400, aucun moyen de paiement),
payment_declined (402, le fournisseur a refusé : reason vaut card_declined, insufficient_funds,
card_expired, cvv_declined ou card_restricted ; la réponse garde success: false),
domain_not_found (404) et conflict (409, plan tarifaire par défaut non supprimable). Avant
cette harmonisation, error y contenait un texte anglais et message était absent.
Les routes de compte (/auth/me/password, /auth/me/email, /auth/me/phone, /auth/verifications) ajoutent leurs propres codes (invalid_password, invalid_code, code_expired, too_many_attempts, email_in_use, invalid_phone, sms_unavailable...), listés dans Vérification par code.
Les autres erreurs HTTP ont la même forme, avec pour error le nom de l'erreur en minuscules
(bad_request, unsupported_media_type, ...), et gardent leurs en-têtes HTTP (Allow d'un 405,
Content-Range d'un 416 sur un fichier...). Quelques routes d'administration ont leurs propres
codes, indiqués dans la référence.
Pagination
Les listes acceptent skip (décalage, 0 par défaut ; une valeur négative compte pour 0) et
limit (taille de page, 50 par défaut, ramenée dans l'intervalle 1 à 100). Une valeur qui n'est
pas un entier donne 400 validation_error avec field (skip ou limit), jamais une erreur
500. La réponse porte la liste sous le nom de la ressource, plus :
total est le nombre d'éléments qui répondent aux filtres de la requête, quelle que soit la page.
Cette forme est celle de 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 et de la plupart des autres listes : la
référence donne total, skip et limit dans le retour de chaque route qui les
rend. Le solde se lit avec GET /api/v1/billing/balance.
Temps réel (Socket.IO)
Le serveur Socket.IO est sur le même hôte que l'API (chemin /socket.io). Le client donne son
jeton d'accès à la connexion (io(url, { auth: { token } })) ; sans jeton valide la connexion
est refusée avec unauthorized (jeton inconnu ou révoqué) ou token_expired (le client
rafraîchit son jeton puis se reconnecte).
Salles. À la connexion, le socket rejoint sans rien demander :
user_<id>: la salle personnelle de l'usager (événements qui ne concernent que lui) ;team_<id>: une salle par équipe à laquelle l'usager a accès (commeGET /conversations: toutes les équipes de l'organisation courante avecteams.all, sinon la sienne). Avecauth: { token, member_only: true }(ou?member_only=1), seulement les équipes de la vue de l'app mobile, jugée avec les profils de chaque adhésion :teams.all(Admin, Manager) toutes les équipes, sinon seulement l'équipe où le membre est placé (rien sans équipe) ; une permission de plateforme n'ajoute rien, donc undomain_adminne suit pas toutes les organisations de son domaine (même règle quemember_onlydeGET /conversations).
Mode administration. Un compte qui a platform_admin ou domain_admin peut ouvrir la connexion avec
io(url, { auth: { token, admin_organization: "<organization_id>" } }) (ou ?admin_organization=) :
mêmes règles que l'en-tête X-Admin-Organization (sans ces permissions : connexion refusée forbidden ;
organisation inconnue : not_found ; admin de domaine hors de ses domaines : forbidden). Le
socket rejoint alors user_<id>, admin_org_<organization_id> et la salle de chaque équipe de
cette organisation seulement (pas celles de ses propres appartenances). La salle
admin_org_<id> reçoit balance_update et account_state de l'organisation, et unread_update
avec les totaux de toutes ses équipes. L'organisation est fixée pour la vie du socket : join_team
et mark_read hors d'elle sont ignorés. La connexion est journalisée (audit_logs, type
admin_realtime_connected) et les salles sont retirées si le compte perd sa permission de plateforme.
Un client peut encore émettre join_team {team_id} (salle d'équipe, refusé sans droit
d'accès), leave_team {team_id} (ne quitte que la salle d'équipe) et mark_read
{conversation_id}. Une équipe qui apparaît après la connexion (nouvelle équipe, usager placé
dans une équipe) se rejoint par join_team ou à la reconnexion.
| Événement (serveur vers client) | Salle | Charge utile |
|---|---|---|
new_message |
équipe | {message, conversation} |
message_status |
équipe | {message_id, status} |
conversation_update |
équipe | {conversation, change: read, closed, reopened, archived ou renamed, type, cause?, message_id?, contact_name?} : type = la même valeur que change ; renamed (contact renommé ou nom effacé) porte contact_name, le nouveau nom ou null |
conversation_note |
équipe | {note, change, conversation_id} |
user_joined, user_left |
équipe | {user_id} |
balance_update |
user_<id> de chaque membre de l'organisation |
{balance, currency, state} : nouveau solde, CAD, state = ok, low (si l'organisation a réglé une alerte de solde bas : solde à ou sous son seuil, jamais low quand l'alerte est éteinte ; sinon sous le seuil de la recharge automatique quand elle est active, sinon sous 10 $) ou empty (zéro ou moins) ; émis à chaque crédit ou débit |
unread_update |
user_<id> des usagers qui ont accès à l'équipe |
{conversations, messages} : conversations avec des messages non lus et nombre de messages non lus, toutes équipes accessibles de l'organisation courante de l'usager, conversations fermées comprises ; émis à chaque message entrant et à chaque lecture |
call_request |
user_<id> de l'usager qui a fait la demande |
{id, status, call_id?} : changement d'état d'une demande d'appel (pending, used, connected, expired, cancelled, failed) ; les demandes se créent par POST /api/v1/voice/call-requests |
account_state |
user_<id> de chaque membre de l'organisation, et admin_org_<id> en mode administration |
{state, previous_state, balance, currency, negative_since, grace_ends_at, suspended_at, limbo_at, release_at, released_at} : état du compte prépayé (dates UTC en ISO 8601 avec Z) ; émis à chaque transition et à chaque prolongation du délai |
low_balance |
user_<id> de chaque membre de l'organisation |
{balance, threshold, currency} : le solde vient de passer sous le seuil de l'alerte de solde bas |
auto_recharge_failed |
user_<id> de chaque membre de l'organisation |
{reason, amount, balance, currency} : la recharge automatique a échoué |
voicemail_new |
user_<id> de chaque membre de l'organisation qui peut écouter ce message (même règle que GET /api/v1/voice/voicemails) |
{voicemail} : un message vocal vient d'être déposé dans une boîte de l'organisation ; relisez GET /api/v1/voice/voicemails, qui ne donne que les messages que l'usager peut écouter |
call_update |
user_<id> de chaque membre de l'organisation qui voit cet appel (même règle que GET /api/v1/voice/calls) |
{call} : appel entrant ou appel manqué |
dnd_update |
user_<id> de l'usager |
{dnd: {active, until, set_at}} : son « Ne pas déranger » vient de changer (PUT /auth/me/dnd, depuis un autre appareil) ; même objet que GET /auth/me/dnd |
port_update |
user_<id> de chaque membre de l'organisation |
{id, state, numbers: [{number, state}]} : une demande de portage entrant de l'organisation a changé d'état |
« Chaque membre de l'organisation » désigne les usagers actifs dont c'est l'organisation courante.
Les événements balance_update et unread_update donnent l'état courant : le client remplace sa
valeur, il n'additionne pas.
Limites de débit
| Requêtes | Limite | Comptées par |
|---|---|---|
| toute route sans limite propre | 500 par heure et 2000 par jour, pour chaque route | clé API, utilisateur ou adresse IP (voir ci-dessous) |
POST /api/v1/sms/send |
100 par heure | utilisateur, ou clé API autre que celle d'une intégration |
POST /api/v1/sms/send avec la clé d'une intégration |
600 par heure | clé |
POST /auth/login |
30 échecs par 15 minutes ; si l'adresse du client est connue, aussi 10 par 15 minutes par courriel et adresse, et 100 par 15 minutes par adresse | adresse courriel envoyée ; adresse du client |
POST /auth/signup |
10 par heure ; 20 par heure si l'adresse du client est connue | adresse courriel envoyée ; adresse du client |
PUT /auth/me/password, PUT /auth/me/email, PUT /auth/me/phone |
10 par heure | utilisateur |
POST /auth/verifications |
30 par heure, en plus des limites d'envoi de codes (voir Vérification par code) | utilisateur |
POST /auth/verifications/<id>/confirm |
60 par heure | utilisateur |
POST /auth/register, POST /auth/reset-password |
10 par heure | jeton envoyé (d'invitation, de réinitialisation) |
DELETE /auth/me (suppression de son compte) |
5 par heure | utilisateur |
PATCH /auth/me quand le corps change le mot de passe (password, déprécié) |
10 par heure | utilisateur |
POST /api/v1/keys (créer une clé) |
10 par heure | utilisateur |
POST /api/v1/organizations/<id>/members/<user_id>/impersonate |
20 par heure | utilisateur |
POST /api/v1/domains/<id>/number-market/order (achat de numéros pour la banque du domaine) |
30 par heure | utilisateur |
POST /api/v1/voice/diagnostics (rapport de diagnostic d'un appel) |
120 par heure | utilisateur |
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 (cartes) |
30 par heure | utilisateur ou clé |
POST /webhook/numbers/<jeton> (livraison du fournisseur de numéros) |
120 par minute | adresse IP (voir ci-dessous) |
POST /auth/forgot-password |
5 par heure | adresse courriel envoyée |
POST /widget/<clé>/start (widget de site web) |
10 par minute et 60 par heure ; 3 par heure et 10 par jour vers un même numéro | widget ; numéro de destination, tous widgets confondus |
POST /api/v1/integrations/<id>/test |
10 par minute | organisation de l'intégration, toutes ses intégrations confondues |
POST /auth/mfa/verify (deuxième étape de la connexion) |
10 échecs par 15 minutes | compte du mfa_token, quel que soit le jeton |
POST /auth/webauthn/login (connexion par clé d'accès) |
30 échecs par 15 minutes ; si l'adresse du client est connue, aussi 30 échecs par 15 minutes par adresse | clé d'accès présentée (son identifiant) ; adresse du client |
POST /auth/webauthn/login/options |
60 par 15 minutes si l'adresse du client est connue (sinon aucune limite) | adresse du client |
POST /auth/mfa/webauthn/options |
60 par 15 minutes | compte du 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 échecs par 15 minutes | compte (session ou X-MFA-Token) |
POST /auth/oauth/google/callback, POST /auth/oauth/microsoft/callback, POST /auth/oauth/apple/callback |
30 échanges par minute, en plus des limites par défaut | fournisseur, pour toute la plateforme : seuls les échanges de code envoyés au fournisseur comptent |
Une route qui a sa propre limite n'est pas soumise aux limites par défaut. Celles-ci sont comptées
route par route : lire 500 conversations n'entame pas le compteur de GET /api/v1/teams.
Une requête est comptée dès son arrivée, avant tout contrôle : toutes comptent, y compris celles qui sont refusées (400, 401, 403...), à cinq exceptions près :
- les requêtes
OPTIONS(pré-vérification CORS des navigateurs) ne comptent jamais ; POST /auth/loginne compte que ses échecs (401) : une connexion réussie ou une requête malformée (400) ne consomme rien, et le compteur est vérifié avant le contrôle du mot de passe ;POST /api/v1/integrations/<id>/testn'est compté qu'une fois l'authentification et la permission vérifiées : une requête sans session valide (401), d'un usager sansintegrations.manage(403) ou avec un en-têteX-API-Key(403) ne compte pas. Un appel sur une intégration inconnue (404) ou que l'usager n'administre pas (403) compte pour lui seul, pas pour l'organisation de l'intégration ;- sur
POST /widget/<clé>/start, seuls les SMS de bienvenue confiés au fournisseur comptent (une requête refusée, un numéro invalide, le piège à robots, un SMS déjà envoyé au même numéro dans la minute ou un SMS que Telma n'a pas pu confier au fournisseur, faute de crédit ou de fournisseur activé, ne comptent pas). Chaque SMS est compté juste avant d'être confié au fournisseur : des demandes simultanées ne dépassent pas ces limites. Le compteur par numéro est celui du numéro réellement visé, quelle que soit son écriture : « 514 555 9999 » et « +1 514 555 9999 » sont un seul et même numéro. « + » suivi de 10 chiffres dépend du fournisseur du numéro du widget : avec un fournisseur qui lit les numéros nord-américains, « +5145559999 » est lu comme 1-514-555-9999 (le numéro qu'il texte) ; avec un fournisseur qui envoie le numéro international tel quel, « +47 923 45 678 » reste un numéro norvégien ; - le plafond commun des callbacks OAuth (
POST /auth/oauth/google/callback,POST /auth/oauth/microsoft/callback,POST /auth/oauth/apple/callback) fonctionne de la même façon : un callback refusé avant l'échange (corps invalide, fournisseur non activé pour le domaine) ne le touche pas, et chaque échange est compté juste avant d'être envoyé au fournisseur. Les limites par défaut de ces routes comptent, elles, chaque requête dès son arrivée.
Ne sont soumis à aucune limite : les webhooks entrants des fournisseurs
(/webhook/sms, /webhook/domain, /webhook/provider et /webhook/billing, mais pas /webhook/numbers/<jeton>, limité plus haut), les fichiers téléversés (GET /api/v1/uploads/<fichier>) et le relais des médias
(GET /api/v1/media-proxy), que les navigateurs chargent sans clé, ainsi que le script du widget de
site web (GET /widget/<clé>.js), que charge chaque visiteur des sites qui l'intègrent.
Les routes publiques de la page de connexion ne sont pas limitées non plus :
GET /auth/oauth/providers, GET /auth/oauth/google, GET /auth/oauth/microsoft et GET /auth/oauth/apple
(qui ne font que construire l'adresse de connexion), POST /auth/oauth/apple/return (qui ne fait que vérifier la
signature du state et renvoyer le navigateur vers sa page), GET /auth/signup/config et GET /api/v1/domains/current.
Chaque requête est comptée, dans cet ordre :
- sur les routes d'authentification anonymes, par leur cible, quels que soient les autres en-têtes
(
X-API-Key,Authorization) : l'adresse courriel envoyée pourPOST /auth/login,POST /auth/forgot-passwordetPOST /auth/signup, le jeton envoyé pourPOST /auth/registeretPOST /auth/reset-password, le code d'autorisation envoyé pourPOST /auth/oauth/google/callback,POST /auth/oauth/microsoft/callbacketPOST /auth/oauth/apple/callback(par adresse IP si le corps n'en a pas ; leurs échanges avec le fournisseur ont en plus le plafond commun du tableau), et le jeton de l'adresse pourGET /api/v1/invitations/verify/<jeton>; - avec une clé
X-API-Keyvalide (existante et non révoquée), par clé : tous les usagers de votre application qui passent par la même clé partagent ses compteurs ; - avec un jeton JWT valide, par utilisateur (pour
POST /auth/refreshetPOST /auth/logout, l'utilisateur du jeton de rafraîchissement) ; un jeton que Telma a signé et qui a expiré depuis moins d'une heure est compté à part, pour son utilisateur : il reçoit donc401, qui fait rafraîchir la session, et jamais un429dû au trafic anonyme, sans jamais entamer les compteurs des requêtes valides de cet utilisateur (un jeton expiré depuis plus longtemps compte comme une requête anonyme) ; - sinon, par adresse IP du client.
Une clé inventée ou révoquée ne crée pas de compteur : la requête est comptée comme sans clé
(étapes 1, 3 ou 4 ci-dessus). Sur une route qui exige une authentification, elle reçoit 401, ou 403
sur les routes fermées à toute clé dès qu'un en-tête X-API-Key est présent
(/api/v1/integrations* et celles qui donneraient un autre moyen de se connecter, voir
Clé API). Les compteurs du widget de site web (par widget, par numéro de destination)
ignorent X-API-Key, Authorization et l'adresse du visiteur.
Adresse IP. Derrière le relais TLS de l'hôte de production (sslh), toutes les requêtes arrivent
de 127.0.0.1 : Telma ne connaît pas l'adresse réelle de ses clients. Une adresse loopback,
privée ou locale n'est jamais prise pour celle d'un client (utils/client_ip.py) : les limites
« par adresse » (budgets d'échecs de POST /auth/login, inscription) ne s'appliquent alors pas, au
lieu d'être un seul compteur pour toute la plateforme. Les autres compteurs « par adresse » (routes
anonymes sans cible) restent communs à tous les clients anonymes, route par route : sans clé ni
JWT, vos requêtes partagent les limites par défaut avec celles de tout le monde, et un 429 peut
venir du trafic des autres. Authentifiez vos appels (clé API ou JWT) pour avoir vos propres
compteurs. Limite d'infrastructure : l'adresse réelle apparaît sans changement de code dès que
sslh la transmet (mode transparent, ou protocole PROXY entre sslh et nginx avec le module realip
de nginx : set_real_ip_from 127.0.0.1; real_ip_header proxy_protocol;). Si nginx pose plutôt une
adresse unique dans un en-tête (proxy_set_header X-Real-IP $remote_addr;), le nom de cet en-tête
va dans la variable d'environnement TRUSTED_CLIENT_IP_HEADER ; Telma ne lit jamais un en-tête que
le client peut envoyer à sa guise.
Les réponses des routes limitées portent ces en-têtes, exposés aux navigateurs par CORS :
| En-tête | Contenu |
|---|---|
X-RateLimit-Limit |
le plafond en cours : celui de la plus courte période, ou celui qui est atteint |
X-RateLimit-Remaining |
les requêtes qui restent dans cette période |
X-RateLimit-Reset |
la fin de cette période, en secondes Unix |
Retry-After |
sur un 429 seulement : le nombre de secondes à attendre |
Au-delà du plafond, la réponse est un 429 en JSON ; retry_after (un entier, au moins 1) et
Retry-After ont la même valeur :
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}
Un envoi refusé n'est pas mis en file
Un 429 est rendu avant tout traitement : le SMS n'est ni envoyé, ni enregistré, ni mis en attente
chez Telma. C'est à votre application de le réessayer après Retry-After secondes ; un SMS
automatique (rappel, confirmation...) qu'elle ne réessaie pas est perdu. Si vos envois
approchent du plafond, étalez-les ou mettez-les en file de votre côté.
Exemples de client qui réessaie après un 429, en respectant Retry-After (testés contre le vrai 429
de l'API) ; au-delà de max_wait secondes d'attente, ils rendent l'erreur pour que vous remettiez
l'envoi dans votre propre file :
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 : renvoie le message créé ; réessaie après un 429, lève TextosError sinon."""
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: # réponse d'un intermédiaire (502, 504...) : pas du 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: # rien n'a été envoyé : on peut réessayer
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"] est déjà enregistré en échec, ne pas renvoyer à l'aveugle
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 : renvoie le message créé ; réessaie après un 429, lève TextosError sinon
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),
});
// une réponse d'un intermédiaire (502, 504...) n'est pas du JSON
const body = await response.json().catch(() => ({ error: `http_${response.status}`, message: "" }));
if (response.status === 201) return body.message;
if (response.status === 429) { // rien n'a été envoyé : on peut réessayer
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 est déjà enregistré en échec, ne pas renvoyer à l'aveugle
throw new TextosError(response.status, body);
}
}
Supervision
GET /health (sans authentification, hors limites) répond 200 {"status": "ok", "mongo": true} quand l'API
et sa base répondent, 503 {"status": "degraded", "mongo": false} sinon : c'est la sonde des outils de
supervision, pas une route pour vos clients.
GET /api/uploads/<fichier> est l'ancienne adresse des fichiers téléversés : elle redirige (301) vers
GET /api/v1/uploads/<fichier>.