Le fetch natif de Node pour le premier message, puis le code de production : client réutilisable, envoi hors du cycle de requête, webhooks vérifiés. Avec les cinq pièges de Node qui font échouer une intégration en recette — dont un qui ne se voit pas.
Conexteo ne publie pas de bibliothèque JavaScript. L'API est une REST classique en JSON sur HTTPS : le fetch intégré à Node depuis la version 18 couvre l'intégralité des appels. Une dépendance de moins dans votre package.json, un npm audit de moins à surveiller, et aucun SDK à mettre à jour quand l'API évolue.
Si votre code utilise déjà axios ou undici, rien ne change : les exemples ci-dessous se transposent ligne pour ligne. Une seule différence de comportement compte vraiment, et elle fait l'objet de la section 3.
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.
const HEADERS = {
"X-APP-ID": process.env.CONEXTEO_APP_ID,
"X-API-KEY": process.env.CONEXTEO_API_KEY,
"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 avec des identifiants fraîchement copiés, échangez les deux en-têtes avant toute autre hypothèse.
Une dizaine de lignes suffisent à valider que tout fonctionne :
const reponse = await fetch("https://api.conexteo.com/messages/sms", {
method: "POST",
headers: HEADERS,
body: JSON.stringify({
content: "Votre code de connexion : 847291",
sender: "MaBoutique",
recipients: ["+33612345678"],
}),
signal: AbortSignal.timeout(10_000),
});
console.log(reponse.status, await reponse.json());
// 200 { success: true, message: 'message created',
// message_id: 123456, credits_used: 1 }Le corps doit être une chaîne. Passer directement l'objet à body sans JSON.stringify envoie littéralement [object Object], et l'API répond 400. Contrairement à axios, fetch ne sérialise rien pour vous et ne pose pas non plus le Content-Type — d'où sa présence dans les en-têtes ci-dessus.
Second détail : AbortSignal.timeout(). Le fetch de Node n'a aucun délai d'expiration par défaut. Sur un envoi déclenché depuis une route web, une requête qui ne revient pas immobilise le traitement jusqu'au timeout du serveur HTTP.
C'est le piège qui ne se voit pas, et celui qui coûte le plus cher en production. fetch ne lève une exception que si la connexion réseau échoue. Un 400, un 401, un 422 : la promesse est résolue normalement. Un try/catch autour de l'appel n'attrapera jamais rien.
// ✗ Ce code ne détecte jamais une erreur d'API
try {
const res = await fetch(url, options);
const data = await res.json();
return data.message_id; // undefined si l'API a répondu 400
} catch (err) {
// on n'arrive ici que si le réseau est tombé
}
// ✓ Il faut tester res.ok explicitement
const res = await fetch(url, options);
if (!res.ok) {
throw new Error(`Conexteo ${res.status} : ${await res.text()}`);
}
return (await res.json()).message_id;La signature de ce bug en production : des envois qui « réussissent » dans vos journaux, un message_id à undefined en base, et des clients qui n'ont rien reçu. Aucune alerte ne se déclenche, puisque rien n'a levé d'exception.
Si vous utilisez axios, le problème est inversé : axios lève sur tout code ≥ 400, y compris le 409, qui est pourtant un succès. Voir la section suivante.
En production, on veut trois choses que le premier exemple n'a pas : une erreur typée, une reprise sur panne serveur uniquement, et un traitement correct du 409.
// conexteo.mjs
const BASE = "https://api.conexteo.com";
export class ConexteoError extends Error {
constructor(status, corps) {
super(`Conexteo ${status} : ${corps}`);
this.name = "ConexteoError";
this.status = status;
}
}
const pause = (n) => new Promise((r) => setTimeout(r, 2 ** n * 500));
async function appeler(chemin, charge, { essais = 3 } = {}) {
for (let essai = 0; ; essai++) {
let res;
try {
res = await fetch(BASE + chemin, {
method: "POST",
headers: HEADERS,
body: JSON.stringify(charge),
signal: AbortSignal.timeout(10_000),
});
} catch (err) {
// panne réseau ou expiration : on peut rejouer
if (essai < essais - 1) { await pause(essai); continue; }
throw err;
}
// 409 : le message existe déjà. C'est un succès, pas une erreur.
if (res.status === 409) return { deja_envoye: true };
// On ne rejoue que les erreurs serveur. Jamais un 4xx.
if (res.status >= 500 && essai < essais - 1) { await pause(essai); continue; }
if (!res.ok) throw new ConexteoError(res.status, await res.text());
return res.json();
}
}
export const envoyerSms = (charge) => appeler("/messages/sms", charge);Ne rejouez jamais un 4xx. Un 400 rejoué trois fois reste un 400, et chaque tentative consomme du temps de traitement. Seules les erreurs serveur et les pannes réseau méritent une reprise — avec un délai qui double à chaque essai, comme ci-dessus.
Le champ external_id est ce qui rend le 409 utile : il empêche l'API de créer un doublon si le même message part deux fois. Donnez-lui une valeur déterministe, issue de votre métier :
await envoyerSms({
content: `Votre commande ${commande.reference} est expédiée.`,
sender: "MaBoutique",
recipients: [client.mobile],
external_id: `expedition-${commande.id}`, // déterministe
});Node est mono-thread. Un appel réseau de deux secondes dans un gestionnaire de route ne bloque pas la boucle d'événements, mais il maintient la requête ouverte — et votre client attend. Pire : si l'API est lente, vos utilisateurs le ressentent alors que le SMS n'a rien à voir avec la réponse attendue.
import { Queue } from "bullmq";
const file = new Queue("notifications");
app.post("/commandes", async (req, res) => {
const commande = await creerCommande(req.body);
res.status(201).json(commande); // on répond d'abord
await file.add("sms-expedition", { // puis on empile
commandeId: commande.id,
});
});La tentation à éviter : appeler l'API sans await pour « ne pas attendre ». Une promesse non attendue qui échoue devient un unhandledRejection, et selon votre configuration Node, le processus s'arrête. Vous aurez échangé une latence de deux secondes contre un redémarrage de conteneur.
Le champ scheduleAt attend une date en UTC, au format ISO 8601. C'est le deuxième piège le plus fréquent, et il ne se manifeste qu'en production, une à deux heures après l'heure prévue.
// ✗ Chaîne sans fuseau : interprétée en heure locale du serveur
const quand = "2026-09-15T09:00:00";
// ✓ On exprime l'heure voulue avec son fuseau, puis on convertit
const quand = new Date("2026-09-15T09:00:00+02:00").toISOString();
// "2026-09-15T07:00:00.000Z"
await envoyerSms({
content: "Votre rendez-vous est demain à 14h.",
sender: "Cabinet",
recipients: ["+33612345678"],
scheduleAt: quand,
});Un conteneur Docker tourne presque toujours en UTC, une machine de développement en Europe/Paris. Le même code produit donc deux résultats différents selon l'endroit où il s'exécute : l'envoi part à la bonne heure sur votre poste, et deux heures trop tôt en production l'été. Exprimez toujours le fuseau dans la chaîne d'entrée, et laissez toISOString() produire le Z final.
| Champ | Requis | Description |
|---|---|---|
| content | oui | Le texte du message. |
| recipients | oui | Tableau de numéros, format national ou international : ["0606060606", "+33606060606"]. Jusqu'à un million d'entrées. Une chaîne seule provoque un 400. |
| sender | non | Expéditeur alphanumérique, 11 caractères maximum. Au-delà, l'API répond 400 — la chaîne n'est pas tronquée. |
| external_id | non | Identifiant de déduplication. Une seconde soumission avec la même valeur renvoie un 409. |
| scheduleAt | non | Horodatage en UTC, ISO 8601. Absent = envoi immédiat. |
| shorturl | non | Raccourcissement de lien : {"mode":"unique","url":"…"}. Le mode unique génère un lien par destinataire, donc un suivi de clic individuel. |
| Code | Cause la plus probable | Que faire en Node |
|---|---|---|
| 200 | Message accepté et mis en file. | Conserver message_id — c'est la clé de rapprochement des webhooks. |
| 400 | Objet passé à body sans JSON.stringify, expéditeur de plus de 11 caractères, ou recipients passé en chaîne au lieu de tableau. | Journaliser await res.text() : il nomme le champ fautif. Attention, le corps ne se lit qu'une fois. |
| 401 | App 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. |
| 409 | Un message portant le même external_id a déjà été traité. C'est un succès idempotent. | Le traiter avant le test res.ok. Avec axios, l'ajouter à validateStatus, sans quoi une tâche rejouée est marquée en échec. |
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, qui n'est émis qu'en mode signé.
En Express, la vérification de signature échoue systématiquement pour une raison qui n'a rien à voir avec la cryptographie : express.json() consomme le flux et vous laisse un objet JavaScript. Le condensat se calcule sur les octets reçus, pas sur une re-sérialisation — dont l'ordre des clés et les espaces ne correspondront jamais à l'original.
import express from "express";
import crypto from "node:crypto";
const app = express();
const SECRET = process.env.CONEXTEO_WEBHOOK_SECRET; // whsec_…
// Le nom exact de l'en-tête de signature figure dans votre espace
// Mon Compte / API / Webhook. On le lit depuis l'environnement.
const EN_TETE = process.env.CONEXTEO_SIGNATURE_HEADER;
app.post(
"/webhooks/conexteo",
express.raw({ type: "application/json" }), // ← le corps reste un Buffer
(req, res) => {
const attendue = crypto
.createHmac("sha256", SECRET)
.update(req.body) // Buffer brut, pas l'objet
.digest("hex");
const recue = Buffer.from(req.get(EN_TETE) ?? "", "utf8");
const ref = Buffer.from(attendue, "utf8");
// timingSafeEqual lève si les longueurs diffèrent : on teste avant.
if (recue.length !== ref.length || !crypto.timingSafeEqual(recue, ref)) {
return res.sendStatus(401);
}
const evenement = JSON.parse(req.body);
const idEvenement = req.get("X-Conexteo-Webhook-Id");
// Conexteo réessaie huit fois sur environ dix-huit heures.
// Le même événement peut donc arriver plusieurs fois.
if (dejaTraite(idEvenement)) return res.sendStatus(200);
traiter(evenement); // rapide, ou empilé dans une file
res.sendStatus(200);
},
);Si express.json() est déclaré globalement par un app.use() plus haut dans le fichier, il s'applique aussi à cette route et le express.raw() local arrive trop tard. Déclarez la route de webhook avant le middleware global, ou conservez le corps brut via l'option verify de express.json().
Répondez 200 vite. Un traitement long fait expirer la requête côté Conexteo, qui considère l'envoi comme échoué et réessaie — vous recevez alors le même événement encore et encore. Accusez réception d'abord, travaillez ensuite.
Node est le langage où le RCS se lit le mieux, pour une raison simple : un message RCS est un objet JavaScript. Là où les autres langages construisent des tableaux associatifs imbriqués, vous écrivez la structure telle qu'elle part sur le réseau.
export const envoyerRcs = (charge) => appeler("/messages/rcs", charge);
await envoyerRcs({
type: "card",
data: {
title: "Votre commande est prête",
description: "Retrait en boutique jusqu'à samedi 19h.",
media: "https://exemple.fr/colis.jpg", // jamais de WebP
buttons: [
{ type: "url", title: "Suivre", url: "https://…" },
{ type: "location", title: "Itinéraire" },
],
},
fallback: { // parti en SMS si le mobile ne gère pas le RCS
content: "Votre commande est prête. Retrait jusqu'à samedi 19h.",
sender: "MaBoutique",
},
contacts: [{ recipient: "+33612345678" }],
});
// La réponse sépare les deux compteurs :
// { rcs: { sent: 412 }, sms: { sent: 88 } }
// → 82 % de votre base a reçu la version enrichie.Le champ s'appelle contacts, pas recipients. C'est la première erreur quand on recopie le code SMS : l'API répond 400. En contrepartie, chaque entrée peut porter des variables libres, réutilisables dans le corps du message.
Les deux compteurs de la réponse sont la mesure la plus utile de toute l'API : ils vous donnent le taux de compatibilité RCS réel de votre base, campagne après campagne. Aucune statistique de marché ne remplace ce chiffre.
Les trois formats — texte, carte, carrousel —, les quatre types de boutons et leurs contraintes exactes sont documentés sur la page API RCS. Le client Node ci-dessus fonctionne pour les trois : seul le contenu de data change.
Non. Conexteo ne publie pas de bibliothèque JavaScript, et il n'en faut pas : l'API est une REST en JSON sur HTTPS, que le fetch intégré à Node depuis la version 18 couvre entièrement. Si votre projet utilise déjà axios, undici ou got, les exemples se transposent sans changement de logique.
Parce que fetch ne rejette que sur une panne réseau. Un 400 ou un 401 résout la promesse normalement : il faut tester res.ok explicitement. La signature du problème en production : des envois qui paraissent réussir, un message_id à undefined en base, et des destinataires qui n'ont rien reçu.
Presque toujours parce que express.json() a déjà consommé le corps de la requête. Le condensat HMAC doit être calculé sur les octets reçus ; une re-sérialisation de l'objet analysé ne redonne jamais exactement la même chaîne. Utilisez express.raw({ type: "application/json" }) sur cette route, déclarée avant tout middleware JSON global.
Non : le 409 indique qu'un message portant le même external_id a déjà été accepté. C'est la garantie qu'une tâche rejouée après un délai d'expiration n'enverra pas le message deux fois. Traitez-le comme un succès. Avec axios, pensez à l'ajouter à validateStatus, sinon la bibliothèque lèvera une exception sur ce qui est un bon résultat.
Oui. Le client est identique, seul l'endpoint change : /messages/rcs au lieu de /messages/sms. Deux différences à connaître : le tableau de destinataires s'appelle contacts et non recipients, et un objet fallback définit le SMS envoyé automatiquement aux mobiles non compatibles.
Le champ scheduleAt est interprété en UTC. Une chaîne comme "2026-09-15T09:00:00", sans indication de fuseau, est lue en heure locale du processus — et un conteneur tourne presque toujours en UTC alors que votre poste est en Europe/Paris. Écrivez le fuseau dans la chaîne d'entrée et convertissez avec toISOString().
L'inscription est gratuite et sans carte bancaire, avec cinq SMS de test pour vérifier le rendu réel sur mobile. Vos identifiants d'API sont disponibles immédiatement dans Mon Compte / API / Webhook.