Guide développeur

Envoyer un SMS en C# : API REST, .NET et ASP.NET Core

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.

SmsService.cs
var res = await _http.PostAsJsonAsync(
  "messages/sms", new {
    content = "Code : 847291",
    sender  = "MaBoutique",
    recipients = new[]{ "+336…" }
  });
✓ 200 · message_id: 123456
REST + JSON
aucun paquet NuGet à installer
.NET 6+
HttpClient et System.Text.Json suffisent
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 NuGet, et c'est voulu

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.

1. Récupérer ses identifiants et les configurer

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.

2. Le client typé, enregistré une fois pour toutes

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.

3. Ne jamais envoyer depuis un contrôleur

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.

4. Programmer un envoi à une date future

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.

Référence des champs

ChampRequisDescription
contentouiLe texte du message.
recipientsouiTableau 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 C#
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, ou nom de champ non reconnu — souvent un JsonPropertyName oublié.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é.Tester le code avant EnsureSuccessStatusCode(), 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.

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.

Et en langage C, sans .NET ?

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.

Le réglage que les développeurs oublient

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.

Questions fréquentes

Comment envoyer un SMS en C# sans installer de paquet NuGet ?
HttpClient et System.Text.Json, tous deux inclus dans le framework, suffisent. 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 un tableau de destinataires.

Conexteo ne publie volontairement pas de SDK .NET : l'API est assez simple pour ne pas justifier une dépendance supplémentaire à maintenir, et vous gardez la main sur le timeout, la politique de retry et la sérialisation. Le code complet, prêt à copier, figure plus haut sur cette page.
Comment configurer HttpClient pour appeler une API SMS dans ASP.NET Core ?
Par un client typé enregistré avec AddHttpClient<T> dans Program.cs. Vous y posez une fois pour toutes l'adresse de base, le timeout et les deux en-têtes d'authentification ; la classe reçoit ensuite un HttpClient déjà configuré par injection de dépendances.

L'intérêt n'est pas seulement esthétique : la fabrique gère le cycle de vie des connexions, ce qu'un new HttpClient() appelé à chaque envoi ne fait pas — c'est la cause classique d'épuisement de sockets en production. Et le client typé se remplace par un faux en test unitaire, sans mock d'une bibliothèque tierce.
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 configuration côté .NET.
Pourquoi mes accents partent-ils en séquences é dans le corps JSON ?
Parce que System.Text.Json échappe par défaut tout caractère hors ASCII. Le JSON produit reste valide et le SMS arrive correctement ; en revanche le corps de requête gonfle et devient pénible à relire en journalisation.

La correction tient en une ligne : passer un JsonSerializerOptions avec Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping. Le nom est impressionnant mais l'assouplissement ne concerne que l'échappement HTML, sans effet sur un corps JSON envoyé à une API.
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 commande-4821-expediee. 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 piège propre à .NET est que EnsureSuccessStatusCode() lève une exception sur ce 409. Testez le code explicitement avant de l'appeler : un rejeu après timeout réseau serait sinon compté en échec alors que le client a bien reçu son SMS.
Comment programmer l'envoi d'un SMS à une date précise en C# ?
Avec le champ scheduleAt, qui accepte 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 de votre côté.

L'erreur la plus fréquente est d'y mettre un DateTime.Now. Le message part alors avec une à deux heures d'avance selon la saison, et le bug reste invisible sur un serveur réglé en UTC. Convertissez explicitement avec TimeZoneInfo.ConvertTimeToUtc, ou raisonnez en DateTimeOffset.
Peut-on appeler l'API SMS depuis un programme en langage C ?
Oui. L'API n'impose ni SDK ni bibliothèque particulière : n'importe quel programme capable d'émettre une requête HTTPS avec trois en-têtes et un corps JSON peut l'utiliser. En C, libcurl couvre le besoin en une vingtaine de lignes, ce qui convient aux usages de supervision industrielle ou d'alerting embarqué.

La seule précaution : échapper les données variables avant de les concaténer dans le JSON. Un guillemet dans un nom de capteur produit un corps invalide et un 400 difficile à diagnostiquer depuis un équipement distant.

Votre premier SMS en C# 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