Un HttpClient, deux en-têtes, un objet sérialisé. Ce guide donne le code réel — client typé, injection de dépendances, service en arrière-plan, réception des webhooks — et les quatre pièges propres à .NET qui font échouer une intégration en recette.
Conexteo ne publie pas de SDK .NET. L'API est une REST classique en JSON sur HTTPS : HttpClient et System.Text.Json, tous deux dans le framework depuis .NET Core 3.1, couvrent l'intégralité des appels. Vous n'ajoutez aucune dépendance à suivre à chaque montée de version, et vous gardez la main sur la politique de retry, le timeout et la sérialisation.
C'est aussi ce qui rend l'intégration testable : un client typé enregistré dans le conteneur d'injection de dépendances se remplace par un faux en test unitaire, sans mock d'une bibliothèque tierce. Le seul écosystème PHP mis à part — un bridge Symfony Notifier existe côté PHP —, aucun langage ne dispose d'un paquet officiel.
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. En développement, elles vont dans les secrets utilisateur plutôt que dans appsettings.json :
dotnet user-secrets init dotnet user-secrets set "Conexteo:AppId" "votre-app-id" dotnet user-secrets set "Conexteo:ApiKey" "votre-cle-api" dotnet user-secrets set "Conexteo:Sender" "MaBoutique"
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.
La bonne pratique en .NET n'est pas d'instancier un HttpClient à chaque envoi, mais de déclarer un client typé auprès de la fabrique. L'adresse de base et les en-têtes d'authentification sont posés une fois, au démarrage :
Program.cs
builder.Services.AddHttpClient<ConexteoClient>(client =>
{
var cfg = builder.Configuration.GetSection("Conexteo");
client.BaseAddress = new Uri("https://api.conexteo.com/");
client.Timeout = TimeSpan.FromSeconds(10);
client.DefaultRequestHeaders.Add("X-APP-ID", cfg["AppId"]);
client.DefaultRequestHeaders.Add("X-API-KEY", cfg["ApiKey"]);
});ConexteoClient.cs
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Encodings.Web;
public sealed record SmsRequest(
[property: JsonPropertyName("content")] string Content,
[property: JsonPropertyName("recipients")] string[] Recipients,
[property: JsonPropertyName("sender")] string? Sender = null,
[property: JsonPropertyName("external_id")] string? ExternalId = null,
[property: JsonPropertyName("scheduleAt")] string? ScheduleAt = null);
public sealed record SmsResponse(
[property: JsonPropertyName("success")] bool Success,
[property: JsonPropertyName("message_id")] long MessageId,
[property: JsonPropertyName("credits_used")] int CreditsUsed);
public sealed class ConexteoClient(HttpClient http, IConfiguration cfg)
{
private static readonly JsonSerializerOptions Json = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
public async Task<SmsResponse?> SendSmsAsync(
string[] recipients, string content, string? externalId = null,
CancellationToken ct = default)
{
var payload = new SmsRequest(content, recipients,
cfg["Conexteo:Sender"], externalId);
var response = await http.PostAsJsonAsync("messages/sms", payload, Json, ct);
// 409 = message déjà soumis avec cet external_id. Ce n'est pas une erreur.
if (response.StatusCode == HttpStatusCode.Conflict)
return null;
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<SmsResponse>(Json, ct);
}
}Deux réglages de sérialisation à ne pas oublier. D'abord les noms de champs : l'API attend external_id et recipients en minuscules, quand la convention C# produit ExternalId. Sans les attributs JsonPropertyName, le champ part sous un nom que l'API ignore — et un external_id ignoré, c'est la protection contre les doublons qui ne s'applique plus, sans le moindre message d'erreur.
Ensuite l'encodage : par défaut, System.Text.Json échappe tout ce qui sort de l'ASCII. Votre message part alors truffé de séquences é. Le JSON reste valide et le SMS arrive correctement, mais le corps de requête gonfle inutilement et devient illisible en journalisation. UnsafeRelaxedJsonEscaping règle la question — le nom fait peur, il ne concerne que le HTML.
Un envoi de SMS est un appel réseau sortant. Placé dans le chemin d'une requête HTTP entrante, il en fait dépendre le temps de réponse : si l'opérateur ralentit, c'est votre utilisateur qui attend. Le schéma robuste consiste à mettre l'envoi en file et à le traiter dans un service d'arrière-plan.
// File en mémoire, suffisante pour un volume modéré.
// Au-delà, préférez une file persistante — sinon un redémarrage perd les messages.
public sealed class SmsQueue
{
private readonly Channel<SmsJob> _channel = Channel.CreateUnbounded<SmsJob>();
public ValueTask EnqueueAsync(SmsJob job) => _channel.Writer.WriteAsync(job);
public IAsyncEnumerable<SmsJob> ReadAllAsync(CancellationToken ct)
=> _channel.Reader.ReadAllAsync(ct);
}
public sealed class SmsWorker(SmsQueue queue, ConexteoClient conexteo,
ILogger<SmsWorker> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var job in queue.ReadAllAsync(ct))
{
try
{
await conexteo.SendSmsAsync(
[job.Phone], job.Text, externalId: job.ExternalId, ct);
}
catch (HttpRequestException ex)
{
log.LogError(ex, "Envoi échoué pour {ExternalId}", job.ExternalId);
// Le rejeu est sûr : l'external_id empêche le doublon.
}
}
}
}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.
Or EnsureSuccessStatusCode() lève une exception sur un 409. Le scénario qui piège : votre appel expire côté réseau, le worker rejoue, 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 du code 409 avant l'appel à EnsureSuccessStatusCode() dans le client ci-dessus. Sans external_id, le même incident produit deux SMS facturés au même destinataire.
Le champ scheduleAt évite d'avoir à maintenir un planificateur de votre côté : vous soumettez le message au moment où l'événement métier se produit, l'API le garde et le libère à l'heure dite.
// Un rappel la veille à 10 h, heure de Paris.
var paris = TimeZoneInfo.FindSystemTimeZoneById("Europe/Paris");
var local = new DateTime(2026, 9, 15, 10, 0, 0, DateTimeKind.Unspecified);
var utc = TimeZoneInfo.ConvertTimeToUtc(local, paris);
var payload = new SmsRequest(
Content: "Rappel : votre rendez-vous est demain à 14h30.",
Recipients: [phone],
Sender: "MonCabinet",
ExternalId: $"rdv-{appointmentId}-j1",
ScheduleAt: utc.ToString("yyyy-MM-ddTHH:mm:ssZ"));Le champ attend de l'UTC, et DateTime.Now n'en est pas. C'est l'erreur la plus fréquente sur ce champ, et elle passe la recette sans se voir : en hiver la campagne part avec une heure d'avance, en été avec deux. Une campagne prévue à 8 h partirait donc à 6 h — hors des plages autorisées pour un message promotionnel. Utilisez TimeZoneInfo.ConvertTimeToUtc ou un DateTimeOffset, jamais DateTime.Now. Et méfiez-vous du serveur de production réglé en UTC qui fait passer le bug inaperçu jusqu'au déploiement chez un client hébergé ailleurs.
| 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. |
| 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 C# |
|---|---|---|
| 200 | Message accepté et mis en file. | Conserver message_id — c'est la clé de rapprochement des webhooks. |
| 400 | Expéditeur de plus de 11 caractères, ou nom de champ non reconnu — souvent un JsonPropertyName oublié. | Journaliser le corps de la réponse : il nomme le champ fautif. |
| 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é. | Tester le code avant EnsureSuccessStatusCode(), qui lèverait une exception. |
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.
app.MapPost("/webhooks/conexteo", async (
HttpRequest req, IMemoryCache cache, SmsQueue queue) =>
{
// Corps brut : indispensable pour vérifier la signature.
req.EnableBuffering();
using var reader = new StreamReader(req.Body, leaveOpen: true);
var raw = await reader.ReadToEndAsync();
req.Body.Position = 0;
// Idempotence : le même identifiant revient à chaque tentative.
var id = req.Headers["X-Conexteo-Webhook-Id"].ToString();
if (!string.IsNullOrEmpty(id) && !cache.TryGetValue(id, out _))
{
cache.Set(id, true, TimeSpan.FromDays(2));
var evt = JsonSerializer.Deserialize<WebhookEvent>(raw);
// Traiter ici, ou mettre en file si le traitement est long.
}
return Results.NoContent(); // 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é.
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 avec GET /webhooks/deliveries, qui expose les statuts pending, delivered, failed et cancelled.
En mode signé, la vérification tient en quelques lignes. Le point important est de calculer l'empreinte sur le corps brut — d'où le EnableBuffering() ci-dessus — et de comparer en temps constant :
// Le nom exact de l'en-tête de signature figure dans votre espace
// Mon Compte / API / Webhook, à côté du secret (whsec_…).
var recue = req.Headers[cfg["Conexteo:SignatureHeader"]!].ToString();
var cle = Encoding.UTF8.GetBytes(cfg["Conexteo:WebhookSecret"]!);
using var hmac = new HMACSHA256(cle);
var calculee = Convert.ToHexString(
hmac.ComputeHash(Encoding.UTF8.GetBytes(raw)))
.ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(calculee),
Encoding.UTF8.GetBytes(recue)))
{
return Results.Unauthorized();
}Deux erreurs classiques sur cette vérification. Calculer l'empreinte sur l'objet désérialisé plutôt que sur le corps brut : la sérialisation réordonne les clés et change le résultat, tout échoue sans raison apparente. Et comparer avec ==, qui expose à une attaque temporelle : CryptographicOperations.FixedTimeEquals existe pour cela.
Le cas existe — supervision industrielle, systèmes embarqués, alerting bas niveau. L'API ne demande rien de particulier : libcurl suffit, et le corps JSON peut être construit à la main tant qu'il reste simple.
CURL *curl = curl_easy_init();
struct curl_slist *headers = NULL;
headers = curl_slist_append(headers, "X-APP-ID: VOTRE_APP_ID");
headers = curl_slist_append(headers, "X-API-KEY: VOTRE_CLE_API");
headers = curl_slist_append(headers, "Content-Type: application/json");
const char *body =
"{\"content\":\"Alerte : température hors seuil.\","
"\"sender\":\"Supervision\","
"\"recipients\":[\"+33612345678\"]}";
curl_easy_setopt(curl, CURLOPT_URL, "https://api.conexteo.com/messages/sms");
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
CURLcode res = curl_easy_perform(curl);Une précaution vaut d'être rappelée dans ce contexte : si le message contient des données variables, échappez-les avant de les concaténer dans le JSON. Un guillemet dans un nom de capteur suffit à produire un corps invalide et un 400 difficile à diagnostiquer depuis un équipement distant.
Une alerte de supervision 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.
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 →