Guide développeur

Envoyer un SMS en PHP : API REST, Laravel et cURL

Une requête HTTP POST, trois en-têtes, un tableau de destinataires. Ce guide donne le code réel — cURL, PHP natif, Guzzle, Laravel — la référence des champs, les codes d'erreur et la réception des accusés de réception par webhook.

send.php
$r = $http->post(
  'https://api.conexteo.com
  /messages/sms', [
  'content' => 'Code : 847291',
  'sender'  => 'MaBoutique',
  'recipients' => ['+336…'],
]);
✓ 200 · message_id: 123456
REST + JSON
aucun SDK à installer
PHP ≥ 7.4
ext-curl ou Guzzle suffisent
3 en-têtes
app id, clé API, type de contenu
5 SMS
offerts pour tester, sans carte bancaire

Pourquoi il n'y a pas de SDK PHP à installer

Autant le dire tout de suite : Conexteo ne publie pas de bibliothèque PHP officielle, et n'en publie pas non plus pour Laravel. Ce n'est pas un manque à combler, c'est un choix qui vous arrange. L'API est une REST classique en JSON sur HTTPS : un client HTTP que vous avez déjà — l'extension cURL de PHP, Guzzle, le client Symfony, `Http` de Laravel — suffit à tout faire. Vous n'ajoutez aucune dépendance à maintenir, aucune couche d'abstraction à comprendre, et vous restez libre de changer de route SMS sans réécrire votre code métier.

Une seule intégration packagée existe côté PHP, et elle est pour Symfony : le bridge connected-company/conexteo-notifier, sous licence MIT, qui branche Conexteo sur le composant Notifier. On y revient plus bas.

1. Récupérer ses identifiants

Créez un compte sur app.conexteo.com, puis ouvrez Mon Compte / API / Webhook. Vous y trouvez deux valeurs distinctes, et c'est important : un App ID et une clé API. Les deux voyagent dans des en-têtes séparés, sur chaque requête.

X-APP-ID: votre-app-id X-API-KEY: votre-cle-api Content-Type: application/json

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 alors que vos identifiants sont fraîchement copiés, échangez les deux en-têtes avant toute autre hypothèse.

2. Le premier envoi, en ligne de commande

Avant d'écrire une seule ligne de PHP, envoyez un message depuis un terminal. Cela isole la question « mes identifiants fonctionnent-ils ? » de la question « mon code est-il correct ? », et c'est dix secondes de gagnées sur chaque diagnostic ultérieur.

curl --location 'https://api.conexteo.com/messages/sms' \ --header 'X-APP-ID: votre-app-id' \ --header 'X-API-KEY: votre-cle-api' \ --header 'Content-Type: application/json' \ --data '{ "content": "Votre code de connexion : 847291", "sender": "MaBoutique", "recipients": ["+33612345678"] }'

La réponse tient en quatre champs :

{ "success": true, "message": "message created", "message_id": 123456, "credits_used": 1 }

Chiffrer un envoi sans le déclencher. Ajoutez le paramètre de requête ?resumeBefore=true : l'API retourne le nombre de crédits que la campagne consommerait, sans envoyer un seul message. C'est le moyen propre de valider une segmentation ou d'afficher un coût estimé à un utilisateur avant confirmation.

3. En PHP, avec l'extension cURL

Sans aucune dépendance, sur n'importe quel hébergement mutualisé :

<?php $payload = [ 'content' => 'Votre commande #4821 vient d\'être expédiée.', 'sender' => 'MaBoutique', 'recipients' => ['+33612345678'], ]; $ch = curl_init('https://api.conexteo.com/messages/sms'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_HTTPHEADER => [ 'X-APP-ID: ' . getenv('CONEXTEO_APP_ID'), 'X-API-KEY: ' . getenv('CONEXTEO_API_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE), ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($status !== 200) { throw new RuntimeException("Conexteo a répondu $status : $body"); } $data = json_decode($body, true); // $data['message_id'] à conserver : c'est la clé de rapprochement des webhooks.

Deux détails qui font échouer un envoi sur trois en recette. L'expéditeur est limité à 11 caractères — « MaBoutiqueEnLigne » sera refusé, pas tronqué. Et l'encodage : sans JSON_UNESCAPED_UNICODE, les accents partent en séquences d'échappement qui gonflent inutilement le message et peuvent le faire basculer sur deux segments facturés.

4. Dans Laravel : un service, puis une file d'attente

Laravel embarque déjà tout ce qu'il faut avec le client Http. Le seul vrai conseil d'architecture : n'appelez jamais l'API depuis un contrôleur. Un envoi de SMS est un appel réseau sortant ; s'il rame, c'est la requête de votre utilisateur qui rame. Encapsulez-le dans un service, déclenchez-le depuis une Job en file d'attente.

config/services.php

'conexteo' => [ 'app_id' => env('CONEXTEO_APP_ID'), 'api_key' => env('CONEXTEO_API_KEY'), 'sender' => env('CONEXTEO_SENDER', 'MaBoutique'), ],

app/Services/Conexteo.php

<?php namespace App\Services; use Illuminate\Support\Facades\Http; class Conexteo { public function sms(array $recipients, string $content, ?string $externalId = null): array { $response = Http::withHeaders([ 'X-APP-ID' => config('services.conexteo.app_id'), 'X-API-KEY' => config('services.conexteo.api_key'), ]) ->timeout(10) ->acceptJson() ->post('https://api.conexteo.com/messages/sms', array_filter([ 'content' => $content, 'sender' => config('services.conexteo.sender'), 'recipients' => $recipients, 'external_id' => $externalId, ])); if ($response->status() === 409) { // Message déjà soumis avec cet external_id : ce n'est pas une erreur. return ['duplicate' => true]; } return $response->throw()->json(); } }

app/Jobs/SendSms.php

public $tries = 3; public $backoff = [10, 60, 300]; public function handle(Conexteo $conexteo): void { $conexteo->sms( [$this->order->phone], "Votre commande #{$this->order->id} vient d'être expédiée.", externalId: "order-{$this->order->id}-shipped" ); }

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

Le scénario qui piège : votre appel expire côté réseau, votre Job est rejouée, l'API répond 409, votre code lève une exception, la Job échoue et vous croyez que le client n'a rien reçu. Il a reçu le message. C'est pour cela qu'un 409 doit être traité comme un succès idempotent, jamais comme une erreur. Sans external_id, le même incident produit deux SMS facturés au même destinataire.

Et sous Symfony : le bridge Notifier

C'est la seule intégration PHP packagée. Elle branche Conexteo sur le composant Notifier de Symfony, ce qui vous permet d'envoyer un SMS avec la même API que vos autres canaux de notification. PHP 8.1 minimum, licence MIT.

composer require connected-company/conexteo-notifier # .env CONEXTEO_DSN=conexteo://APP_ID:API_KEY@default?sender=SENDER

Le code source est public : github.com/connected-company/conexteo-notifier. Notez que le DSN reprend la même paire App ID / clé API que les en-têtes HTTP — le piège de l'interversion s'applique donc aussi ici, en première et seconde position du DSN.

Référence des champs

ChampRequisDescription
contentouiLe texte du message.
recipientsouiTableau de numéros, au format national ou international : ["0606060606", "+33606060606"]. Jusqu'à un million d'entrées.
sendernonExpéditeur alphanumérique, 11 caractères maximum.
external_idnonVotre identifiant de déduplication. Une seconde soumission avec la même valeur renvoie un 409.
scheduleAtnonHorodatage d'envoi en UTC, ISO 8601 — 2026-09-15T08:00:00Z. Absent = envoi immédiat.
shorturlnonRaccourcissement de lien : {"mode":"unique","url":"…"}. Le mode unique génère un lien par destinataire, donc un suivi des clics individuel.

Le champ scheduleAt attend de l'UTC. Un envoi programmé depuis un serveur réglé sur Europe/Paris sans conversion partira avec une ou deux heures d'avance selon la saison. En PHP : (new DateTime('2026-09-15 10:00', new DateTimeZone('Europe/Paris')))->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z'). En été, une campagne prévue à 8 h partirait à 6 h — hors des plages autorisées pour un message promotionnel.

Les codes de retour et ce qu'ils veulent dire

CodeCause la plus probableQue faire
200Message accepté et mis en file.Conserver message_id : c'est la clé de rapprochement des webhooks.
400Expéditeur de plus de 11 caractères, recipients envoyé comme chaîne au lieu d'un tableau, ou scheduleAt mal formaté.Journaliser le corps de la réponse : 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é.Traiter comme un succès. Le message est parti ; ne pas rejouer.

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

L'envoi n'est que la moitié du travail. Ce qui distingue une intégration jouet d'une intégration de production, c'est de savoir ce qu'est devenu chaque message. 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 de vos destinataires — y compris les clics sur les suggestions RCS —, et ack_rcs pour les statuts RCS.

Deux modes existent, et le choix n'est pas anodin. Un webhook non signé est envoyé une seule fois : si votre serveur redémarre au mauvais moment, l'événement est perdu définitivement. 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 recevoir les statuts RCS. En production, il n'y a pas vraiment de débat.

// routes/web.php — endpoint public, exclu de la vérification CSRF Route::post('/webhooks/conexteo', function (Request $request) { // Idempotence : le même identifiant revient à chaque tentative. $id = $request->header('X-Conexteo-Webhook-Id'); if (! Cache::add("cnx:$id", true, now()->addDays(2))) { return response()->noContent(); // déjà traité } match ($request->input('type')) { 'ack_sms' => DeliveryStatus::record($request->all()), 'reply' => InboundMessage::store($request->all()), default => null, }; return response()->noContent(); // 204 = livré });

En mode signé, chaque requête porte en plus une signature HMAC calculée avec le secret de votre compte — celui qui commence par whsec_. La vérification tient en quelques lignes, et le point important est d'utiliser une comparaison à temps constant plutôt que === :

// Le nom exact de l'en-tête de signature figure dans votre espace // Mon Compte / API / Webhook, à côté du secret. $signature = $request->header(config('services.conexteo.signature_header')); $secret = config('services.conexteo.webhook_secret'); // whsec_… $attendue = hash_hmac('sha256', $request->getContent(), $secret); if (! $signature || ! hash_equals($attendue, $signature)) { abort(401); // requête non authentifiée : on ne traite pas }

Deux erreurs classiques sur cette vérification. Calculer l'empreinte sur le tableau décodé plutôt que sur le corps brut : un simple réordonnancement des clés lors du décodage change la signature, et tout échoue sans raison apparente — utilisez toujours $request->getContent(). Et comparer avec ===, qui expose à une attaque temporelle : hash_equals() existe pour cela.

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 d'attente. Un webhook qui attend la fin d'un traitement de huit secondes finit par être considéré en échec.

L'en-tête X-Conexteo-Webhook-Id reste identique entre toutes les tentatives d'un même événement : c'est votre garantie contre les doublons, à condition de le stocker. Et vous pouvez auditer l'ensemble à tout moment avec GET /webhooks/deliveries, qui expose les statuts pending, delivered, failed et cancelled.

Le réglage que les développeurs oublient

Un code de confirmation et une promotion 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, eux, 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 applicables est sur la page RGPD et SMS marketing.

Questions fréquentes

Comment envoyer un SMS en PHP sans installer de bibliothèque ?
L'extension cURL, présente par défaut sur pratiquement tous les hébergements PHP, suffit. Vous ouvrez une requête POST vers https://api.conexteo.com/messages/sms, vous ajoutez trois en-têtes — X-APP-ID, X-API-KEY et Content-Type: application/json — et vous envoyez un corps JSON contenant le texte et un tableau de destinataires.

Conexteo ne publie volontairement pas de SDK PHP : l'API REST est suffisamment simple pour ne pas justifier une dépendance supplémentaire à maintenir, et cela vous laisse libre du client HTTP que vous préférez. Le code complet, prêt à copier, figure plus haut sur cette page.
Existe-t-il un package Laravel pour envoyer des SMS avec Conexteo ?
Non, et ce n'est pas un obstacle : le client Http de Laravel fait le travail en une dizaine de lignes. La bonne pratique consiste à encapsuler l'appel dans une classe de service, à le déclencher depuis une Job en file d'attente avec quelques tentatives et un délai croissant, et à stocker le message_id retourné pour rapprocher les webhooks de livraison.

Côté Symfony en revanche, un bridge officiel existe pour le composant Notifier : connected-company/conexteo-notifier, sous licence MIT, qui se configure par un simple DSN.
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, testez 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 requête échoue ?
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.

Le point important est la manière de traiter ce 409 : c'est un succès, pas une erreur. Le message d'origine est bien parti. Si votre code lève une exception sur ce code de retour, une Job rejouée après un timeout réseau sera marquée en échec alors que le client a reçu son SMS.
Comment recevoir les réponses des destinataires et les statuts de livraison ?
Par webhook. Vous déclarez une URL par type d'événement : ack_sms pour les accusés de réception, reply pour les réponses entrantes — y compris les clics sur les boutons RCS — et ack_rcs pour les statuts RCS. Votre serveur répond en 2xx pour accuser réception.

Choisissez le mode signé : il apporte une signature HMAC 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.
Combien de temps faut-il pour intégrer l'envoi de SMS dans une application PHP ?
Quelques minutes pour le premier message de test, une demi-journée pour une intégration de production. Le premier envoi ne demande que trois choses : créer le compte, copier l'App ID et la clé API, exécuter une requête cURL. Les cinq SMS offerts à l'inscription suffisent à valider ce parcours sans carte bancaire.

Le reste du temps ne va pas à l'API mais à votre application : exposer un endpoint pour les webhooks, gérer les statuts d'échec, traiter les réponses STOP dans votre base et séparer les flux transactionnels des campagnes marketing.
Combien coûte l'envoi d'un SMS par API ?
Le tarif est identique par API et depuis l'interface : de 0,065 € HT l'unité sur le pack de 1 000 messages à 0,042 € HT à partir de 100 000, sans abonnement, sans frais de plateforme et sans engagement de volume. Les crédits achetés n'ont pas de date d'expiration.

Le paramètre resumeBefore=true permet de connaître le coût exact d'une campagne avant de la déclencher, ce qui est utile pour afficher une estimation dans votre propre interface. La grille complète est sur la page tarifs SMS.

Votre premier SMS en PHP 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 →
À lire aussi