✆ 01 76 41 10 75

Guide développeur

Envoyer un SMS en Go : net/http, contexte et webhooks

Le package net/http de la bibliothèque standard pour le premier message, puis le code de production : un client réutilisable, un contexte propagé jusqu'à l'appel réseau, l'envoi sorti du handler et des webhooks signés vérifiés. Avec les pièges propres à Go — dont deux qui ne renvoient aucune erreur.

sms.go
req, _ := http.NewRequestWithContext(
  ctx, "POST",
  "https://api.conexteo.com
  /messages/sms", corps)
req.Header.Set("X-APP-ID", appID)
req.Header.Set("X-API-KEY", cle)
res, err := client.Do(req)
✓ 200 · message_id: 123456
REST + JSON
aucun module Go à ajouter
net/http
la bibliothèque standard suffit
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 module Go, et c'est voulu

Conexteo ne publie pas de bibliothèque Go. L'API est une REST classique en JSON sur HTTPS, et la bibliothèque standard couvre l'intégralité des appels : net/http pour le transport, encoding/json pour les corps, crypto/hmac pour vérifier les webhooks. Votre go.mod ne gagne aucune ligne, govulncheck n'a rien de plus à surveiller, et aucun SDK n'est à mettre à jour quand l'API évolue.

Si votre équipe travaille aussi dans d'autres langages, les mêmes appels sont détaillés en Python, Node.js, PHP et C#. Ce guide se concentre sur ce qui est propre à Go : la gestion d'erreur explicite, le contexte, les goroutines et la sérialisation JSON par balises de structure.

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, X-APP-ID et X-API-KEY. Lisez-les au démarrage du programme, et arrêtez-le si l'une manque :

func doitLire(nom string) string {
	v, ok := os.LookupEnv(nom)
	if !ok || v == "" {
		log.Fatalf("variable d'environnement %s absente", nom)
	}
	return v
}

appID := doitLire("CONEXTEO_APP_ID")
cle := doitLire("CONEXTEO_API_KEY")

Premier piège : 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 en régénère une, et le problème persiste. Le moyen mnémotechnique : APP identifie l'application, KEY est le secret. Avec des identifiants fraîchement copiés, échangez les deux en-têtes avant toute autre hypothèse.

Second piège, propre à Go : os.Getenv renvoie une chaîne vide, sans erreur, quand la variable n'existe pas. Un conteneur lancé sans son fichier d'environnement envoie donc des en-têtes vides et reçoit un 401 qui ressemble à un problème de clé. os.LookupEnv et un arrêt immédiat transforment ce 401 mystérieux en un message explicite dans les journaux de démarrage.

2. Le premier envoi

Un programme autonome suffit à valider que tout fonctionne :

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"
)

func main() {
	corps, _ := json.Marshal(map[string]any{
		"content":    "Votre code de connexion : 847291",
		"sender":     "MaBoutique",
		"recipients": []string{"+33612345678"},
	})

	req, _ := http.NewRequest(http.MethodPost,
		"https://api.conexteo.com/messages/sms", bytes.NewReader(corps))
	req.Header.Set("X-APP-ID", os.Getenv("CONEXTEO_APP_ID"))
	req.Header.Set("X-API-KEY", os.Getenv("CONEXTEO_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	client := &http.Client{Timeout: 10 * time.Second}
	res, err := client.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	reponse, _ := io.ReadAll(res.Body)
	fmt.Println(res.StatusCode, string(reponse))
	// 200 {"success":true,"message":"message created",
	//      "message_id":123456,"credits_used":1}
}

Pas de http.Post, pas de http.DefaultClient. Le client par défaut n'a aucun délai d’expiration : une requête qui ne revient pas immobilise la goroutine indéfiniment. Sur un service qui envoie des milliers de messages, ce sont autant de goroutines et de connexions qui s'accumulent sans bruit. Créez votre propre http.Client avec un Timeout, une fois pour toutes.

Le Content-Type n’est pas posé pour vous. Avec http.NewRequest, c'est à vous de l'ajouter. Sans lui, le corps JSON n'est pas interprété et l'API répond 400.

3. Le piège de Go : err == nil ne veut pas dire que le SMS est parti

C'est le piège qui ne se voit pas. client.Do ne renvoie une erreur que si la requête n'a pas abouti au niveau du transport : réseau coupé, DNS en échec, délai dépassé. Un 400, un 401, un 422 : la réponse arrive, err vaut nil, et le code continue comme si tout allait bien.

// ✗ Ce code considère un 400 comme un succès
res, err := client.Do(req)
if err != nil {
	return err // n'arrive qu'en cas de panne réseau ou d'expiration
}
defer res.Body.Close()
var r Reponse
json.NewDecoder(res.Body).Decode(&r)
return nil // r.MessageID vaut 0, et personne n'est prévenu

// ✓ Le code HTTP se teste explicitement
if res.StatusCode != http.StatusOK {
	corps, _ := io.ReadAll(res.Body)
	return fmt.Errorf("conexteo %d : %s", res.StatusCode, corps)
}

La signature de ce bug en production : des envois qui « réussissent » dans vos journaux, un message_id à zéro en base — la valeur nulle d'un int64 —, et des clients qui n'ont rien reçu. Aucune alerte ne se déclenche, puisqu'aucune erreur n'a été renvoyée.

Et fermez toujours le corps. Un res.Body jamais fermé, même sur une réponse d'erreur, empêche le transport de réutiliser la connexion. Le service fonctionne en recette, puis ouvre une nouvelle connexion TLS à chaque envoi en production, jusqu'à épuiser les descripteurs de fichiers. Lisez le corps en entier, puis fermez-le, sur tous les chemins.

4. Le code de production : un client, un contexte

En production, on veut quatre choses que le premier exemple n'a pas : une erreur typée que l'appelant peut inspecter, une reprise sur panne serveur uniquement, un traitement correct du 409, et le context.Context de l'appelant propagé jusqu'à l'appel réseau. Un petit package interne suffit :

// Package conexteo : client minimal de l'API SMS et RCS.
package conexteo

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"time"
)

const baseURL = "https://api.conexteo.com"

type Client struct {
	appID, cle string
	http       *http.Client
}

func Nouveau(appID, cle string) *Client {
	return &Client{appID: appID, cle: cle,
		http: &http.Client{Timeout: 10 * time.Second}}
}

type Erreur struct {
	Statut int
	Corps  string
}

func (e *Erreur) Error() string {
	return fmt.Sprintf("conexteo %d : %s", e.Statut, e.Corps)
}

// ErrDejaEnvoye signale un 409 : le message a déjà été accepté.
var ErrDejaEnvoye = errors.New("conexteo : external_id déjà traité")

func (c *Client) appeler(ctx context.Context, chemin string, charge, sortie any) error {
	corps, err := json.Marshal(charge)
	if err != nil {
		return err
	}
	const essais = 3
	for essai := 0; ; essai++ {
		req, err := http.NewRequestWithContext(ctx, http.MethodPost,
			baseURL+chemin, bytes.NewReader(corps))
		if err != nil {
			return err
		}
		req.Header.Set("X-APP-ID", c.appID)
		req.Header.Set("X-API-KEY", c.cle)
		req.Header.Set("Content-Type", "application/json")

		res, err := c.http.Do(req)
		if err != nil {
			// Panne réseau ou expiration : on rejoue, sauf si
			// l'appelant a lui-même annulé.
			if ctx.Err() == nil && essai < essais-1 && attendre(ctx, essai) == nil {
				continue
			}
			return err
		}
		donnees, _ := io.ReadAll(res.Body)
		res.Body.Close()

		switch {
		case res.StatusCode == http.StatusConflict:
			return ErrDejaEnvoye
		case res.StatusCode >= 500 && essai < essais-1:
			if err := attendre(ctx, essai); err != nil {
				return err
			}
			continue
		case res.StatusCode != http.StatusOK:
			return &Erreur{Statut: res.StatusCode, Corps: string(donnees)}
		}
		if sortie == nil {
			return nil
		}
		return json.Unmarshal(donnees, sortie)
	}
}

// attendre double le délai à chaque essai, et s'interrompt
// si le contexte est annulé pendant la pause.
func attendre(ctx context.Context, essai int) error {
	select {
	case <-time.After(time.Duration(1<<essai) * 500 * time.Millisecond):
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

Ne rejouez jamais un 4xx. Un 400 rejoué trois fois reste un 400. Seules les erreurs serveur et les pannes réseau méritent une reprise, avec un délai qui double à chaque essai. Et la pause elle-même écoute ctx.Done() : un time.Sleep à cet endroit ferait attendre un appelant qui a déjà abandonné.

Un seul client pour tout le programme. Un *http.Client est sûr en accès concurrent et garde ses connexions ouvertes. En créer un par envoi, c'est refaire une poignée de main TLS à chaque message.

Le message lui-même se décrit par une structure, et 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 :

type SMS struct {
	Content    string     `json:"content"`
	Recipients []string   `json:"recipients"`
	Sender     string     `json:"sender,omitempty"`
	ExternalID string     `json:"external_id,omitempty"`
	ScheduleAt *time.Time `json:"scheduleAt,omitempty"`
}

type Reponse struct {
	MessageID   int64 `json:"message_id"`
	CreditsUsed int   `json:"credits_used"`
}

func (c *Client) EnvoyerSMS(ctx context.Context, m SMS) (Reponse, error) {
	var r Reponse
	err := c.appeler(ctx, "/messages/sms", m, &r)
	return r, err
}

// Côté appelant
_, err := client.EnvoyerSMS(ctx, conexteo.SMS{
	Content:    "Votre commande " + cmd.Reference + " est expédiée.",
	Sender:     "MaBoutique",
	Recipients: []string{cmd.Mobile},
	ExternalID: "expedition-" + cmd.ID, // déterministe
})
if err != nil && !errors.Is(err, conexteo.ErrDejaEnvoye) {
	return err
}

5. net/http, Gin, Echo : sortir l'envoi du handler

Un appel réseau de deux secondes dans un handler fait attendre votre client pour un SMS qui n'a rien à voir avec la réponse qu'il attend. Le réflexe en Go est de lancer une goroutine. C'est là qu'arrive le piège le plus déroutant de ce guide : des envois qui échouent par intermittence avec context canceled, alors que l'API n'a rien reçu.

// ✗ Le contexte de la requête meurt quand le handler retourne
func creerCommande(w http.ResponseWriter, r *http.Request) {
	cmd := enregistrer(r)
	go client.EnvoyerSMS(r.Context(), smsConfirmation(cmd))
	w.WriteHeader(http.StatusCreated) // ← r.Context() est annulé ici
}

// ✓ Une file en mémoire, vidée par un travailleur au long cours
var envois = make(chan conexteo.SMS, 1000)

func travailleur(client *conexteo.Client) {
	for m := range envois {
		ctx, annuler := context.WithTimeout(context.Background(), 30*time.Second)
		_, err := client.EnvoyerSMS(ctx, m)
		annuler()
		if err != nil && !errors.Is(err, conexteo.ErrDejaEnvoye) {
			log.Printf("envoi %s : %v", m.ExternalID, err)
		}
	}
}

func creerCommande(w http.ResponseWriter, r *http.Request) {
	cmd := enregistrer(r)
	w.WriteHeader(http.StatusCreated)  // on répond d'abord
	envois <- smsConfirmation(cmd)     // puis on empile
}

Pourquoi le bug est intermittent. Le contexte d'une requête entrante est annulé dès que le handler a répondu. Si la goroutine a déjà émis sa requête, l'envoi aboutit parfois ; si elle était encore en train de se connecter, il échoue. En recette, sur une machine rapide, presque tout passe. En production, une part des messages disparaît.

Si vous tenez à garder les valeurs du contexte — identifiant de trace, utilisateur —, context.WithoutCancel(r.Context()), disponible depuis Go 1.21, conserve les valeurs sans hériter de l'annulation. Ajoutez-lui un délai propre. Pour des volumes réels, ou si un redémarrage ne doit perdre aucun message, remplacez le canal par une file persistante : base de données, Redis, ou le gestionnaire de tâches que votre équipe utilise déjà.

Programmer un envoi à une date future

Le champ scheduleAt attend une date en UTC, au format ISO 8601. Go produit ce format nativement en sérialisant un time.Time. Deux pièges restent, et le premier ne concerne que Go : l'option omitempty n’omet pas une structure time.Time vide.

// ✗ La valeur zéro est sérialisée, pas omise
ScheduleAt time.Time `json:"scheduleAt,omitempty"`
// → "scheduleAt":"0001-01-01T00:00:00Z" sur chaque envoi immédiat

// ✓ Un pointeur, nil quand il n'y a pas de programmation
ScheduleAt *time.Time `json:"scheduleAt,omitempty"`

// ✓ Ou, depuis Go 1.24, l'option omitzero
ScheduleAt time.Time `json:"scheduleAt,omitzero"`

Le second piège est classique : exprimer l'heure voulue sans fuseau. On construit la date dans le fuseau du destinataire, puis on convertit :

import _ "time/tzdata" // embarque la base des fuseaux dans le binaire

paris, err := time.LoadLocation("Europe/Paris")
if err != nil {
	return err
}
quand := time.Date(2026, time.November, 3, 9, 0, 0, 0, paris).UTC()

_, err = client.EnvoyerSMS(ctx, conexteo.SMS{
	Content:    "Votre rendez-vous est demain à 14h.",
	Sender:     "Cabinet",
	Recipients: []string{"+33612345678"},
	ScheduleAt: &quand, // "2026-11-03T08:00:00Z"
})

L’import de time/tzdata n’est pas décoratif. Une image Docker construite FROM scratch ou sur une base minimale ne contient pas la base des fuseaux horaires. time.LoadLocation("Europe/Paris") renvoie alors une erreur — ou, si elle est ignorée, une location vide, et vos rappels partent une à deux heures trop tôt selon la saison. Cet import ajoute quelques centaines de kilo-octets au binaire et supprime le problème.

Référence des champs

ChampRequisDescription
contentouiLe texte du message.
recipientsouiTableau de numéros, format national ou international : ["0606060606", "+33606060606"]. Le format international sert aussi pour les envois hors de France, vers plus de 150 pays. En Go, un []string : une chaîne seule provoque un 400.
sendernonExpéditeur alphanumérique, 11 caractères maximum. Au-delà, l'API répond 400 — la chaîne n'est pas tronquée. Un expéditeur nommé ne permet pas au destinataire de répondre.
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. En Go : *time.Time ou omitzero.
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 Go
200Message accepté et mis en file.Conserver message_id — c'est la clé de rapprochement des webhooks.
400Content-Type absent, expéditeur de plus de 11 caractères, ou recipients sérialisé en chaîne au lieu de tableau.Journaliser le corps de la réponse : il nomme le champ fautif. Ne pas rejouer.
401App ID et clé API intervertis, ou variable d'environnement absente lue par os.Getenv.Échanger les deux en-têtes, vérifier l'environnement, avant de régénérer quoi que ce soit.
409Un message portant le même external_id a déjà été traité. C'est un succès idempotent.Le renvoyer comme erreur sentinelle et le tester avec errors.Is, pour qu'une tâche rejouée ne soit pas marquée en échec.
5xxIncident côté plateforme, transitoire.Rejouer avec un délai croissant, trois tentatives au plus, en respectant le contexte.

Recevoir les accusés de réception : des webhooks signés

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 service redémarre au mauvais moment, l'événement est perdu. Un webhook signé bénéficie des réessais — jusqu'à huit tentatives échelonnées sur environ dix-huit heures — et c'est le seul mode qui transmet les statuts RCS. Il porte trois en-têtes : X-Conexteo-Signature, de la forme v1= suivi d'un HMAC-SHA256 en hexadécimal, X-Conexteo-Timestamp, l'heure d'émission en secondes, et X-Conexteo-Webhook-Id, identique d'une tentative à l'autre. La signature porte sur l’horodatage, un point, puis le corps brut.

package conexteo

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"io"
	"net/http"
	"strconv"
	"strings"
	"time"
)

var ErrSignature = errors.New("signature de webhook invalide")

// VerifierWebhook lit le corps brut, contrôle la signature et
// renvoie le corps pour qu'il soit décodé ensuite.
func VerifierWebhook(r *http.Request, secret string) ([]byte, error) {
	corps, err := io.ReadAll(http.MaxBytesReader(nil, r.Body, 1<<20))
	if err != nil {
		return nil, err
	}

	horodatage := r.Header.Get("X-Conexteo-Timestamp")
	secondes, err := strconv.ParseInt(horodatage, 10, 64)
	if err != nil {
		return nil, ErrSignature
	}
	if d := time.Since(time.Unix(secondes, 0)); d > 5*time.Minute || d < -5*time.Minute {
		return nil, ErrSignature // trop ancien : rejeu possible
	}

	recue, ok := strings.CutPrefix(r.Header.Get("X-Conexteo-Signature"), "v1=")
	if !ok {
		return nil, ErrSignature
	}

	mac := hmac.New(sha256.New, []byte(secret)) // whsec_…
	mac.Write([]byte(horodatage + "."))
	mac.Write(corps) // les octets reçus, jamais une re-sérialisation
	attendue := hex.EncodeToString(mac.Sum(nil))

	if !hmac.Equal([]byte(recue), []byte(attendue)) {
		return nil, ErrSignature
	}
	return corps, nil
}

// Dans le service
func webhook(w http.ResponseWriter, r *http.Request) {
	corps, err := conexteo.VerifierWebhook(r, secret)
	if err != nil {
		http.Error(w, "signature invalide", http.StatusUnauthorized)
		return
	}
	id := r.Header.Get("X-Conexteo-Webhook-Id")
	if dejaTraite(id) {
		w.WriteHeader(http.StatusNoContent) // rejeu : déjà pris en compte
		return
	}
	evenements <- corps                 // traitement hors de la requête
	w.WriteHeader(http.StatusNoContent) // tout 2xx vaut accusé de réception
}

Ne décodez rien avant de vérifier. Un json.NewDecoder(r.Body) placé avant la vérification consomme le flux : il ne reste plus rien à signer. Sous Gin, c.ShouldBindJSON fait la même chose ; lisez le corps avec c.GetRawData(), vérifiez, puis décodez avec json.Unmarshal. Et ne recalculez jamais la signature sur une structure re-sérialisée : l'ordre des champs et les espaces ne correspondront pas à l'original.

Comparez avec hmac.Equal, pas avec ==. La première compare en temps constant, la seconde laisse fuiter la position du premier caractère différent. Et rejetez les horodatages de plus de cinq minutes : c'est ce qui empêche de rejouer une requête interceptée.

Répondez vite. Toute réponse 2xx vaut accusé de réception ; un autre code, un délai dépassé ou une erreur réseau comptent comme un échec et déclenchent un nouvel essai. Un handler qui attend la fin d'un traitement long reçoit le même événement encore et encore.

Et le RCS ? Le même client, un autre endpoint

Le client ci-dessus envoie aussi du RCS : seul le chemin change, /messages/rcs, et la forme du corps. En Go, on décrit le message par des structures plutôt que par des map[string]any imbriquées : une faute de frappe dans un nom de champ devient une erreur de compilation au lieu d'un 400 en production.

type Bouton struct {
	Type  string `json:"type"`
	Title string `json:"title"`
	URL   string `json:"url,omitempty"`
}

type Carte struct {
	Title       string   `json:"title"`
	Description string   `json:"description"`
	Media       string   `json:"media,omitempty"`
	Buttons     []Bouton `json:"buttons,omitempty"`
}

type Repli struct {
	Content string `json:"content"`
	Sender  string `json:"sender,omitempty"`
}

type Contact struct {
	Recipient string `json:"recipient"`
}

type RCS struct {
	Type     string    `json:"type"`
	Data     any       `json:"data"`
	Fallback Repli     `json:"fallback"` // SMS envoyé si le mobile ne gère pas le RCS
	Contacts []Contact `json:"contacts"`
}

type ReponseRCS struct {
	RCS struct {
		Sent int `json:"sent"`
	} `json:"rcs"`
	SMS struct {
		Sent int `json:"sent"`
	} `json:"sms"`
}

func (c *Client) EnvoyerRCS(ctx context.Context, m RCS) (ReponseRCS, error) {
	var r ReponseRCS
	err := c.appeler(ctx, "/messages/rcs", m, &r)
	return r, err
}

r, err := client.EnvoyerRCS(ctx, conexteo.RCS{
	Type: "card",
	Data: conexteo.Carte{
		Title:       "Votre commande est prête",
		Description: "Retrait en boutique jusqu'à samedi 19h.",
		Media:       "https://exemple.fr/colis.jpg", // PNG ou JPG, jamais WebP
		Buttons:     []conexteo.Bouton{{Type: "url", Title: "Suivre", URL: "https://…"}},
	},
	Fallback: conexteo.Repli{Content: "Votre commande est prête. Retrait jusqu'à samedi 19h.", Sender: "MaBoutique"},
	Contacts: []conexteo.Contact{{Recipient: "+33612345678"}},
})
// r.RCS.Sent = 412, r.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. Avec des structures typées, elle se voit à la relecture ; avec une map, elle se découvre au premier 400.

Les deux compteurs de la réponse sont la mesure la plus utile de toute l’API : ils 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. Un envoi RCS nécessite un agent RCS vérifié au nom de votre marque : en attendant sa validation, les messages partent en SMS.

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 transactionnels — codes, confirmations, rappels de rendez-vous — partent à toute heure. Les messages promotionnels sont soumis à des plages horaires et au consentement préalable du destinataire, détaillés sur la page RGPD et SMS marketing.

Si votre application mélange les deux flux, séparez-les dès la conception : deux canaux, deux travailleurs, et jamais un code d'authentification en file derrière un envoi de masse. En Go, c'est une affaire de quelques lignes, et c'est beaucoup plus simple à poser au premier jour qu'à démêler un an plus tard. Pour estimer le coût d'un volume donné, la grille est publique sur la page tarifs SMS.

Questions fréquentes

Existe-t-il un SDK ou un module Go pour envoyer des SMS avec Conexteo ?

Non, et il n'en faut pas. L'API Conexteo est une REST en JSON sur HTTPS, que la bibliothèque standard de Go couvre entièrement : net/http pour les requêtes, encoding/json pour les corps et crypto/hmac pour vérifier les webhooks. Un package interne d'une centaine de lignes suffit à obtenir un client de production, sans aucune dépendance dans le go.mod.

Pourquoi mon envoi de SMS en Go renvoie-t-il err == nil alors que le message n'est pas parti ?

Parce que client.Do ne renvoie une erreur que pour un échec de transport : réseau, DNS ou délai dépassé. Une réponse 400 ou 401 arrive normalement, avec err à nil. Il faut tester res.StatusCode explicitement. Le symptôme en production : des envois qui paraissent réussir, un message_id à zéro en base, et des destinataires qui n'ont rien reçu.

Pourquoi mes SMS envoyés depuis une goroutine échouent-ils avec « context canceled » ?

Parce que la goroutine utilise r.Context(), le contexte de la requête HTTP entrante, qui est annulé dès que le handler a répondu. L'envoi échoue alors selon le moment où l'annulation survient, d'où un bug intermittent. Utilisez un contexte indépendant avec son propre délai, context.WithoutCancel depuis Go 1.21 si vous devez garder ses valeurs, ou mieux, une file vidée par un travailleur.

Pourquoi le champ scheduleAt part-il avec la date 0001-01-01 dans mes envois Go ?

Parce que l'option omitempty n'omet pas une structure time.Time : sa valeur zéro est sérialisée en "0001-01-01T00:00:00Z". Déclarez le champ en *time.Time, laissé à nil pour un envoi immédiat, ou utilisez l'option omitzero disponible depuis Go 1.24. Pensez aussi à convertir l'heure en UTC avec .UTC() avant l'envoi.

Comment vérifier la signature d'un webhook Conexteo en Go ?

Lisez le corps brut avec io.ReadAll avant tout décodage, calculez un HMAC-SHA256 avec votre secret sur l'horodatage de l'en-tête X-Conexteo-Timestamp, un point, puis le corps, et comparez le résultat en hexadécimal à la valeur qui suit v1= dans X-Conexteo-Signature, avec hmac.Equal. Rejetez les horodatages de plus de cinq minutes et dédupliquez sur X-Conexteo-Webhook-Id, car un même événement peut être livré plusieurs fois.

Que signifie un code 409 renvoyé par l'API SMS de Conexteo ?

Le 409 indique qu'un message portant le même external_id a déjà été accepté. Ce n'est pas une erreur : c'est la garantie qu'une tâche rejouée après un délai dépassé n'enverra pas le SMS deux fois. En Go, renvoyez-le sous forme d'erreur sentinelle et testez-le avec errors.Is, pour que l'appelant le traite comme un succès.

Peut-on envoyer un message RCS depuis Go avec le même client que pour les SMS ?

Oui. Le transport, l'authentification et la gestion d'erreur sont identiques, seul l'endpoint change : /messages/rcs au lieu de /messages/sms. Le tableau de destinataires s'appelle contacts et non recipients, et un objet fallback définit le SMS envoyé automatiquement aux mobiles qui ne gèrent pas le RCS. La réponse distingue les deux compteurs, ce qui donne le taux RCS réel de votre base.

Votre premier SMS en Go dans dix minutes

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.