Guide développeur

Envoyer un SMS en Python : API REST, Django et FastAPI

Trois lignes avec requests pour le premier message, puis le code de production : session réutilisée, envoi en tâche de fond, réception des webhooks. Avec les quatre pièges Python qui font échouer une intégration en recette.

sms.py
r = requests.post(
  "https://api.conexteo.com
  /messages/sms",
  headers=headers,
  json={
    "content": "Code : 847291",
    "recipients": ["+336…"],
  })
✓ 200 · message_id: 123456
REST + JSON
aucun paquet PyPI à installer
requests
ou httpx si vous êtes en async
2 en-têtes
app id et clé API, jamais l'un pour l'autre
5 SMS
offerts pour tester, sans carte bancaire

Il n'y a pas de paquet PyPI, et c'est voulu

Conexteo ne publie pas de bibliothèque Python. L'API est une REST classique en JSON sur HTTPS : requests — ou httpx si votre code est asynchrone — couvre l'intégralité des appels. Une dépendance de moins à épingler dans votre requirements.txt, et aucune surprise à la prochaine montée de version.

C'est aussi plus simple à tester : une fonction qui prend une session HTTP en paramètre se teste avec responses ou respx sans avoir à comprendre les entrailles d'un SDK tiers.

1. Les identifiants, et le piège du 401

Créez un compte sur app.conexteo.com, puis ouvrez Mon Compte / API / Webhook. Vous y trouvez deux valeurs distinctes : un App ID et une clé API, qui voyagent dans deux en-têtes séparés.

import os

HEADERS = {
    "X-APP-ID":  os.environ["CONEXTEO_APP_ID"],
    "X-API-KEY": os.environ["CONEXTEO_API_KEY"],
}

Le piège qui coûte le plus d'heures : intervertir les deux valeurs. L'API répond alors 401 Unauthorized — exactement le même code que si la clé était fausse ou révoquée. On cherche donc du côté de la clé, on en régénère une, et le problème persiste. Le moyen mnémotechnique : APP identifie l'application, KEY est le secret. Si vous obtenez un 401 avec des identifiants fraîchement copiés, échangez les deux en-têtes avant toute autre hypothèse.

2. Le premier envoi

Trois lignes suffisent à valider que tout fonctionne :

import requests

response = requests.post(
    "https://api.conexteo.com/messages/sms",
    headers=HEADERS,
    json={
        "content": "Votre code de connexion : 847291",
        "sender": "MaBoutique",
        "recipients": ["+33612345678"],
    },
    timeout=10,
)

print(response.status_code, response.json())
# 200 {'success': True, 'message': 'message created',
#      'message_id': 123456, 'credits_used': 1}

Le paramètre est json=, pas data=. C'est l'erreur la plus fréquente sur cette première requête. Avec data={...}, requests encode le corps en formulaire au lieu de JSON, et l'API répond 400 sans que rien n'indique la cause dans votre code. Le paramètre json= sérialise correctement et pose l'en-tête Content-Type tout seul.

Second détail : le timeout. Sans lui, requests attend indéfiniment. Sur un envoi déclenché depuis une requête web, cela suffit à bloquer un worker.

3. Le code de production : une session, un client

En production, on ne rouvre pas une connexion TLS à chaque message. Une Session réutilise la connexion et porte les en-têtes une fois pour toutes :

import os
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

BASE = "https://api.conexteo.com"


def build_session() -> requests.Session:
    session = requests.Session()
    session.headers.update({
        "X-APP-ID":  os.environ["CONEXTEO_APP_ID"],
        "X-API-KEY": os.environ["CONEXTEO_API_KEY"],
    })
    # On ne rejoue que les erreurs serveur et réseau, jamais un 4xx.
    retry = Retry(total=3, backoff_factor=1,
                  status_forcelist=[500, 502, 503, 504],
                  allowed_methods=["POST"])
    session.mount("https://", HTTPAdapter(max_retries=retry))
    return session


SESSION = build_session()


def send_sms(recipients: list[str], content: str,
             external_id: str | None = None) -> dict | None:
    payload = {
        "content": content,
        "sender": os.environ.get("CONEXTEO_SENDER", "MaBoutique"),
        "recipients": recipients,
    }
    if external_id:
        payload["external_id"] = external_id

    response = SESSION.post(f"{BASE}/messages/sms", json=payload, timeout=10)

    # 409 = message déjà soumis avec cet external_id. Ce n'est pas une erreur.
    if response.status_code == 409:
        return None

    response.raise_for_status()
    return response.json()

Le 409 n'est pas une panne, c'est une protection — et raise_for_status() le traite à l'envers. Le champ external_id est votre identifiant de déduplication. Si un message a déjà été soumis avec la même valeur, l'API répond 409 Conflict — « External ID already processed » — sans renvoyer le message d'origine.

Or raise_for_status() lève une exception sur un 409. Le scénario qui piège : votre appel expire, votre tâche est rejouée, l'API répond 409, l'exception remonte, et vous concluez que le client n'a rien reçu. Il a reçu le message. D'où le test explicite avant l'appel. Sans external_id, le même incident produit deux SMS facturés au même destinataire.

4. Django, FastAPI : sortir l'envoi du cycle de requête

Un envoi de SMS est un appel réseau sortant. Placé dans une vue, il en fait dépendre le temps de réponse : si l'opérateur ralentit, c'est votre utilisateur qui attend. Selon votre pile :

# Django + Celery
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def send_order_sms(self, order_id: int) -> None:
    order = Order.objects.get(pk=order_id)
    try:
        send_sms(
            [order.phone],
            f"Votre commande #{order.id} vient d'être expédiée.",
            external_id=f"order-{order.id}-shipped",
        )
    except requests.HTTPError as exc:
        # Le rejeu est sûr : l'external_id empêche le doublon.
        raise self.retry(exc=exc)


# FastAPI, pour un volume modéré
@app.post("/orders/{order_id}/ship")
async def ship(order_id: int, background: BackgroundTasks):
    background.add_task(send_order_sms_sync, order_id)
    return {"status": "ok"}

BackgroundTasks suffit tant que le processus n'est pas redémarré au mauvais moment — la tâche vit en mémoire. Dès que la perte d'un message devient coûteuse, passez à une file persistante : Celery, RQ ou Dramatiq.

Programmer un envoi à une date future

Le champ scheduleAt évite de maintenir un planificateur : vous soumettez le message quand l'événement métier se produit, l'API le libère à l'heure dite.

from datetime import datetime
from zoneinfo import ZoneInfo

# Un rappel la veille à 10 h, heure de Paris.
local = datetime(2026, 9, 15, 10, 0, tzinfo=ZoneInfo("Europe/Paris"))
utc   = local.astimezone(ZoneInfo("UTC"))

payload = {
    "content": "Rappel : votre rendez-vous est demain à 14h30.",
    "sender": "MonCabinet",
    "recipients": [phone],
    "external_id": f"rdv-{appointment_id}-j1",
    "scheduleAt": utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
}

Deux façons de se tromper sur ce champ, et Python les offre toutes les deux. datetime.now() renvoie une heure locale sans fuseau : la campagne part avec une à deux heures d'avance selon la saison, et une campagne prévue à 8 h partirait à 6 h — hors des plages autorisées pour un message promotionnel. Et .isoformat() sur un objet conscient du fuseau produit +00:00 et non le Z attendu : d'où le strftime explicite ci-dessus.

Référence des champs

ChampRequisDescription
contentouiLe texte du message.
recipientsouiListe de numéros, format national ou international : ["0606060606", "+33606060606"]. Jusqu'à un million d'entrées.
sendernonExpéditeur alphanumérique, 11 caractères maximum. Au-delà, l'API répond 400 — la chaîne n'est pas tronquée.
external_idnonIdentifiant de déduplication. Une seconde soumission avec la même valeur renvoie un 409.
scheduleAtnonHorodatage en UTC, ISO 8601. Absent = envoi immédiat.
shorturlnonRaccourcissement de lien : {"mode":"unique","url":"…"}. Le mode unique génère un lien par destinataire, donc un suivi de clic individuel.

Les codes de retour et ce qu'ils veulent dire

CodeCause la plus probableQue faire en Python
200Message accepté et mis en file.Conserver message_id — c'est la clé de rapprochement des webhooks.
400Corps envoyé avec data= au lieu de json=, expéditeur de plus de 11 caractères, ou recipients passé en chaîne au lieu de liste.Journaliser response.text : il nomme le champ fautif.
401App ID et clé API intervertis, bien plus souvent qu'une clé invalide.Échanger les deux en-têtes avant de régénérer quoi que ce soit.
409Un message portant le même external_id a déjà été traité.Tester le code avant raise_for_status(), qui lèverait une exception.

Recevoir les accusés de réception et les réponses

L'envoi n'est que la moitié du travail. Conexteo pousse trois types d'événements sur l'URL que vous déclarez : ack_sms pour les accusés de réception SMS, reply pour les réponses entrantes — y compris les clics sur les suggestions RCS —, et ack_rcs pour les statuts RCS.

Deux modes existent. Un webhook non signé n'est envoyé qu'une fois : si votre application redémarre au mauvais moment, l'événement est perdu. Un webhook signé porte une signature HMAC et bénéficie des réessais — jusqu'à huit tentatives échelonnées sur environ dix-huit heures (30 secondes, 2 minutes, 10 minutes, 30 minutes, 1 heure, 4 heures, puis 12 heures). C'est aussi le mode requis pour les statuts RCS.

import hmac, hashlib, json, os
from fastapi import FastAPI, Request, Response, HTTPException

app = FastAPI()
SECRET = os.environ["CONEXTEO_WEBHOOK_SECRET"].encode()   # whsec_…
# Le nom exact de l'en-tête de signature figure dans votre espace
# Mon Compte / API / Webhook, à côté du secret.
SIGNATURE_HEADER = os.environ["CONEXTEO_SIGNATURE_HEADER"]


@app.post("/webhooks/conexteo")
async def conexteo_webhook(request: Request):
    raw = await request.body()          # corps brut, avant tout parsing

    attendue = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    recue    = request.headers.get(SIGNATURE_HEADER, "")

    if not hmac.compare_digest(attendue, recue):
        raise HTTPException(status_code=401)

    # Idempotence : le même identifiant revient à chaque tentative.
    event_id = request.headers.get("X-Conexteo-Webhook-Id")
    if event_id and already_processed(event_id):
        return Response(status_code=204)

    event = json.loads(raw)
    enqueue(event)                      # traitement lourd hors requête

    return Response(status_code=204)    # 204 = livré

Toute réponse 2xx vaut accusé de réception. Tout autre code, un timeout ou une erreur réseau compte comme une tentative en échec. Répondez donc avant de faire le travail lourd : accusez réception, puis traitez en file. Un webhook qui attend la fin d'un traitement de huit secondes finit par être considéré en échec et rejoué.

Deux erreurs classiques sur la vérification de signature : calculer l'empreinte sur le dictionnaire déjà décodé — json.loads puis json.dumps réordonne les clés et change le résultat —, et comparer avec == au lieu de hmac.compare_digest, qui compare en temps constant.

Le réglage que les développeurs oublient

Un code de confirmation et une campagne promotionnelle partent par le même endpoint, mais ne relèvent pas du même régime. Les messages promotionnels s'envoient du lundi au vendredi de 8 h à 20 h et le samedi de 10 h à 19 h, hors dimanches et jours fériés — ce sont les recommandations de la CNIL et les usages du secteur, pas un décret, mais elles engagent la réputation de vos routes. Les messages transactionnels ne sont pas concernés et partent 24 h sur 24. Si votre application mélange les deux flux, séparez-les dès la conception : la route dédiée au SMS transactionnel existe pour qu'un code d'authentification ne se retrouve jamais en file derrière un envoi de masse. Le détail des règles est sur la page RGPD et SMS marketing.

Questions fréquentes

Comment envoyer un SMS en Python sans installer de bibliothèque dédiée ?
Avec requests, que la plupart des projets ont déjà. Vous envoyez une requête POST vers https://api.conexteo.com/messages/sms avec deux en-têtes d'authentification — X-APP-ID et X-API-KEY — et un corps JSON contenant le texte et une liste de destinataires.

Conexteo ne publie volontairement pas de paquet PyPI : l'API est assez simple pour ne pas justifier une dépendance de plus à épingler, et vous gardez la main sur le timeout, la politique de rejeu et la sérialisation. Le code complet, prêt à copier, figure plus haut sur cette page.
Pourquoi l'API répond-elle 400 alors que mon dictionnaire semble correct ?
Presque toujours parce que le corps a été passé avec data= au lieu de json=. Avec data=, requests encode en formulaire et n'ajoute pas l'en-tête Content-Type: application/json : l'API reçoit alors quelque chose qu'elle ne sait pas lire.

Deux autres causes fréquentes : un expéditeur de plus de 11 caractères, refusé et non tronqué, et un recipients passé en chaîne alors qu'une liste est attendue. Dans tous les cas, response.text nomme le champ fautif.
Pourquoi l'API répond-elle 401 Unauthorized alors que ma clé est correcte ?
Dans la grande majorité des cas, parce que l'App ID et la clé API ont été intervertis entre les en-têtes X-APP-ID et X-API-KEY. Les deux valeurs se ressemblent, et l'API renvoie le même 401 dans les deux situations : on cherche donc du côté d'une clé expirée, on en régénère une, et le problème reste entier.

Le réflexe à avoir : échanger les deux en-têtes avant toute autre hypothèse. Si le 401 persiste, rejouez la même requête en cURL depuis un terminal : cela tranche immédiatement entre un problème d'identifiants et un problème de code.
Comment éviter d'envoyer deux fois le même SMS quand une tâche est rejouée ?
En renseignant le champ external_id avec une valeur dérivée de votre métier — par exemple order-4821-shipped. Si un message portant le même identifiant a déjà été traité, l'API répond 409 Conflict au lieu d'envoyer un second message. C'est ce qui rend un rejeu Celery sûr par construction.

Le piège propre à Python est que raise_for_status() lève une exception sur ce 409. Testez response.status_code explicitement avant de l'appeler : sinon la tâche est marquée en échec alors que le client a bien reçu son SMS.
Comment envoyer un SMS depuis Django ?
Jamais directement depuis une vue : un appel réseau sortant placé dans le cycle de requête fait dépendre votre temps de réponse de celui de l'opérateur. Encapsulez l'envoi dans une fonction de service, appelez-la depuis une tâche Celery — ou depuis un signal si le déclencheur est un changement d'état en base — et laissez la vue rendre sa réponse immédiatement.

Deux réglages utiles sur la tâche : quelques tentatives avec un délai croissant, et un external_id dérivé de l'objet métier, qui rend le rejeu inoffensif.
Comment programmer l'envoi d'un SMS à une date précise en Python ?
Avec le champ scheduleAt, qui attend un horodatage ISO 8601 en UTC. Vous soumettez le message au moment où l'événement métier se produit, l'API le libère à l'heure dite : aucun planificateur à maintenir.

Python offre deux façons de se tromper. datetime.now() renvoie une heure locale sans fuseau, d'où une à deux heures d'écart selon la saison. Et .isoformat() produit un suffixe +00:00 là où un Z est attendu. Convertissez avec zoneinfo puis formatez explicitement.
Comment recevoir les réponses des destinataires en Python ?
Par webhook : vous exposez un endpoint POST — quelques lignes en FastAPI, Flask ou Django — et vous déclarez son URL dans votre espace client. Trois types d'événements existent : ack_sms, reply et ack_rcs. Répondez en 2xx pour accuser réception, puis traitez en file.

Choisissez le mode signé : il apporte une signature HMAC à vérifier sur le corps brut, et surtout les réessais — jusqu'à huit tentatives réparties sur environ dix-huit heures. Un webhook non signé n'est envoyé qu'une fois et se perd si votre serveur est indisponible à cet instant précis.

Votre premier SMS en Python dans dix minutes

Inscription gratuite, 5 messages de test offerts, sans carte bancaire. L'App ID et la clé API sont disponibles immédiatement après la création du compte.

Créer un compte gratuitement →