Aller au contenu

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 :

Authorization: Bearer <access_token>

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 :

  1. POST /auth/verifications {channel: "email"|"sms", target, purpose?, country?} envoie un code à 6 chiffres (valide 10 minutes, stocké haché) et répond 202 {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 domaine settings.verification.sms_from) ; sans numéro utilisable : 503 sms_unavailable. country (code ISO du pays) précise le pays d'une cible sms ; il se déduit de l'indicatif régional pour un numéro nord-américain.
  2. POST /auth/verifications/<id>/confirm {code} répond {verification: {id, verified: true}, verified_token, expires_in}. Le verified_token est à usage unique et vaut 10 minutes.
  3. PUT /auth/me/email {email, password, verified_token} ou PUT /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 pose phone.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 :

X-API-Key: <clé>

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 compte platform_admin ou domain_admin n'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/invitations et POST /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 contient password, quel que soit l'usager (le créateur de la clé compris) ; son propre mot de passe ne se change d'ailleurs que par PUT /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>/admins et DELETE /api/v1/domains/<id>/admins/<user_id> ;
  • la modification des réglages SMTP et OAuth (PUT /api/v1/domains/<id>/settings/smtp et son test POST .../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 contient hostnames ou name : les noms d'hôte du domaine donnent l'adresse des liens de ses courriels de connexion. display_name et capabilities restent 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/login ne compte que les échecs (401) : 30 par 15 minutes et par adresse courriel, puis 429 rate_limited avec Retry-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} : le state est fait et signé par le serveur (10 minutes, usage unique, lié au fournisseur, au domaine et à la redirect_uri) et l'adresse porte un défi PKCE (S256). Le client garde le state (sessionStorage), refuse un retour dont le state diffère, et l'envoie avec le code à 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 (sub de Google, id de Microsoft, sub d'Apple). Une adresse n'est rattachée à un compte existant que si Google atteste email_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ême 400 oauth_error au 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) vers POST /auth/oauth/apple/return de l'API, qui le renvoie (303) vers cette page avec code, state et, la première fois seulement, user (le nom) ; la page envoie code, state et user à POST /auth/oauth/apple/callback. Ce qu'Apple dit de la personne est l'id_token de l'échange, vérifié avec les clés publiques d'Apple (signature, émetteur, identifiant client, expiration et nonce lié au state). 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}. Le mfa_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_code ou webauthn ; 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 revendication mfa: true.
  • Connexion par clé d'accès. POST /auth/webauthn/login/options puis POST /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'Origin de la requête, qui doit être un hôte d'un domaine connu). L'application mobile n'envoie pas d'Origin mais X-Client-App: ios ou android avec X-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-association et /.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 aussi mfa_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 avec mfa_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_setup porte la deadline ; après, le mot de passe ne donne qu'un mfa_token d'inscription (mfa_enrollment_required, 15 minutes), à mettre dans l'en-tête X-MFA-Token des 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 traiter mfa_required et mfa_enrollment_required.
  • En-têtes. Toute réponse de l'API porte X-Content-Type-Options: nosniff et Referrer-Policy: no-referrer ; les réponses de /auth/* portent aussi Cache-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.x et *.textos.ca ne valent qu'en développement et en test (FLASK_ENV).
  • Journal. Les écritures faites avec X-Admin-Organization sont 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 :

X-Frontend-Host: other.telma.ca

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'ancien super_admin), domain_admin (les domaines de domain_ids : toutes leurs organisations), support. Elles se donnent avec PUT /api/v1/users/<id>/platform-permissions {permissions, domain_ids?} (platform_admin seulement ; 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) et builtin: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 avec members.manage ou members.admin, écriture avec members.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 avec PUT /api/v1/organizations/current/members/<user_id>/profiles (members.admin ; 409 last_admin s'il ne restait plus personne avec members.admin, 402 plan_limit au-delà des utilisateurs du forfait) et une invitation peut en porter (profile_ids de POST /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_limit si des membres se mettraient à utiliser les messages au-delà du forfait, 409 last_admin si une organisation n'aurait plus personne avec members.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.all ouvre les métriques de toutes les équipes ; metrics.team seule, celles de l'équipe où l'on est placé (une autre équipe : 403). La liste des membres (GET /organizations/current/members, members de GET /organizations/<id>) exige members.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.admin dans l'organisation, ou members.manage pour un membre de son équipe qui n'a ni members.manage ni members.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, field role ou profile_ids) ; avec members.manage seul, jamais members.manage ni members.admin. Widgets et intégrations : integrations.manage ; le secret de réception d'une équipe : teams.provider.
  • GET /auth/me donne platform_permissions, organization_permissions (organisation courante) et profile_ids ; GET /api/v1/organizations/my donne permissions pour 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

X-Admin-Organization: <organization_id>

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/notifications donne et change ce que le compte reçoit : email.updates, survey, purchase, login (un courriel à chaque connexion, éteint par défaut), low_balance et son seuil low_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 par should_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 valeur null retire la clé.
  • Suppression de son compte : DELETE /auth/me {password, confirm: true}. Le compte seul de son organisation la ferme avec lui (refus 409 balance_negative si 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 (refus 403 last_admin pour 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_use dit 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/trial donne l'état, POST /billing/trial/activate {country, type?, area_code?, city?} donne un numéro du pool du domaine à l'organisation pour trial.duration_days jours (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 ont billing.manage (mi-parcours, 2 jours, 1 jour, fin) ; à la fin les numéros d'essai retournent dans la réserve. Atteindre trial.minimum_credit de solde convertit l'essai en compte normal. GET /organizations/current/status résume l'état du compte (active, suspended ou trial).

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 (pending dans 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.manage sont prévenus à 80 %. Les appels internes ne sont jamais facturés ; l'international l'est au tarif du domaine. La réponse donne usage (messages et minutes de la période), seats et next_statement.
  • Limites. Au-delà de max_users (membres qui ont messages.read ou messages.send, à l'invitation ou au changement de profils) ou de max_teams (à la création d'une équipe) : 402 plan_limit avec limit (users ou teams), plan, upgrade (le forfait suivant, ou null) et current (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_lines plus lines_per_full_user par utilisateur complet, avec une marge burst_lines au-dessus (par défaut autant que l'inclus, au moins 2) ; la limite des canaux de l'organisation suit (source: subscription dans GET /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 (ligne extra_line, aussi dans l'estimation).
  • Ce qui est facturé. GET /api/v1/billing/overview (billing.view ou billing.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-overview en donne le résumé par organisation pour un domaine, et GET /api/v1/system/billing-overview par 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/conversations sans team_id compris), 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 par POST /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é 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. 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 le url d'un média d'un message, sinon 404 not_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 exemple media.vcf pour 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êtes Range (lecture d'une vidéo par morceaux) ; une URL refusée donne 400 validation_error, un média que le fournisseur ne rend pas 502 fetch_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 503 busy : réessayez quelques secondes plus tard ;
  • dans message.status, sent_via vaut null et sent_by_name ne garde que le sender_name donné à l'envoi (null pour 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. 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 avec status=closed et 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-Delivery dé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_url sont 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) :

{
  "error": "code",
  "message": "Message lisible"
}

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 :

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

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 (comme GET /conversations : toutes les équipes de l'organisation courante avec teams.all, sinon la sienne). Avec auth: { 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 un domain_admin ne suit pas toutes les organisations de son domaine (même règle que member_only de GET /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/login ne 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>/test n'est compté qu'une fois l'authentification et la permission vérifiées : une requête sans session valide (401), d'un usager sans integrations.manage (403) ou avec un en-tête X-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 :

  1. 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 pour POST /auth/login, POST /auth/forgot-password et POST /auth/signup, le jeton envoyé pour POST /auth/register et POST /auth/reset-password, le code d'autorisation envoyé pour POST /auth/oauth/google/callback, POST /auth/oauth/microsoft/callback et POST /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 pour GET /api/v1/invitations/verify/<jeton> ;
  2. avec une clé X-API-Key valide (existante et non révoquée), par clé : tous les usagers de votre application qui passent par la même clé partagent ses compteurs ;
  3. avec un jeton JWT valide, par utilisateur (pour POST /auth/refresh et POST /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 donc 401, qui fait rafraîchir la session, et jamais un 429 dû 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) ;
  4. 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>.