Novae SMS Documentation API

Documentation API

Deux canaux — SMS et e-mail — quatre notions : un devis, un envoi, un statut, un registre. Tous les montants circulent en milli-FCFA entiers — 15000 vaut 15 F — pour qu’aucun arrondi flottant ne s’invite dans une facture.

URL et authentification

Toutes les requêtes partent de https://api-sms.novae.ci et portent votre clé secrète en en-tête. Les clés se créent depuis votre espace client, rubrique Intégration.

Authorization: Bearer sk_live_…

La clé n’est affichée qu’à sa création : nous n’en conservons qu’une empreinte. Perdue, elle se révoque et se remplace — elle ne se retrouve pas. Elle ne doit jamais partir dans du code exécuté par un navigateur ou une application mobile.

Premier envoi

Deux appels : le devis, puis l’envoi.

# 1. Combien ça coûte, sans rien envoyer
curl https://api-sms.novae.ci/v1/messages/preview \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"to":"0707123456","from":"NOVAE","body":"Votre code est 481203"}'

# 2. L'envoi
curl https://api-sms.novae.ci/v1/messages \
  -H "Authorization: Bearer sk_live_…" \
  -H "Idempotency-Key: commande-48127" \
  -H "Content-Type: application/json" \
  -d '{"to":"0707123456","from":"NOVAE","body":"Votre code est 481203","category":"otp"}'
POST /v1/messages/preview

Devis d’un message

Calcule l’encodage, le nombre de segments et le prix exact. N’envoie rien, ne débite rien, ne compte pas dans vos quotas. Le devis retourné est exactement celui qui sera facturé.

Corps de la requête

ChampTypeDescription
tochaîne, requis Destinataire. Formats acceptés au § Numéros.
fromchaîne, requis Expéditeur affiché. 11 caractères maximum pour un alias.
bodychaîne, requis Texte du message, 1600 caractères maximum.
categorychaîne, optionnel otp, transactional (défaut) ou marketing.

Réponse

{
  "to": "+2250707123456",
  "countryCode": "CI",
  "operator": "orange",
  "encoding": "UCS2",
  "segments": 2,
  "blockingChars": ["ç"],   // vide si le message tient en GSM-7
  "unitPriceMilli": 15000,
  "totalPriceMilli": 30000,
  "priceMilli": 30000    // alias de totalPriceMilli
}

blockingChars nomme les caractères qui font basculer le message en UCS-2. Les afficher à vos utilisateurs, ou les remplacer, divise la facture par deux sur les messages longs.

POST /v1/messages

Envoyer un message

Accepte le message, débite le compte et le met en file. L’en-tête Idempotency-Key est obligatoire. La réponse est immédiate : l’acheminement se suit ensuite.

En-têtes

En-têteDescription
Authorizationrequis Bearer suivi de votre clé.
Idempotency-Keyrequis Identifiant unique de votre côté — un numéro de commande, un UUID. Voir § Idempotence.

Réponse 202 Accepted

{
  "id": "8934ab6d-a06c-4706-a335-915773b10fed",
  "status": "queued",
  "to": "+2250707123456",
  "segments": 1,
  "encoding": "GSM7",
  "priceMilli": 15000,
  "createdAt": "2026-08-13T06:34:26.914Z"
}

202 signifie accepté et facturé, pas délivré. Un message que le réseau n’accepte jamais est remboursé automatiquement et l’écriture apparaît dans votre registre.

GET /v1/messages/{id}

Consulter un message

Retourne l’état courant. Un message d’une autre organisation est indistinguable d’un message inexistant : les deux répondent 404.

{
  "id": "8934ab6d-…",
  "to": "+2250707123456",
  "from": "NOVAE",
  "status": "delivered",
  "encoding": "GSM7",
  "segments": 1,
  "priceMilli": 15000,
  "operator": "orange",
  "countryCode": "CI",
  "errorCode": null,
  "errorMessage": null,
  "submittedAt": "2026-08-13T06:34:27.101Z",
  "deliveredAt": "2026-08-13T06:34:31.882Z",
  "createdAt": "2026-08-13T06:34:26.914Z"
}
POST /v1/emails

Envoyer un e-mail

Même contrat que pour le SMS : idempotence obligatoire, débit à l’acceptation, remboursement si le message n’atteint jamais le destinataire. POST /v1/emails/preview renvoie le prix sans rien envoyer.

L’adresse d’expédition doit être déclarée et validée pour votre organisation avant tout envoi. Sans cela, la requête est refusée en sender_id_not_approved. Aucun client n’envoie depuis un domaine partagé : la réputation d’envoi de chacun lui appartient.

Corps de la requête

ChampTypeDescription
tochaîne, requisAdresse du destinataire.
fromchaîne, requisVotre adresse d’expédition validée.
subjectchaîne, requis250 caractères maximum.
textchaîneCorps en texte brut.
htmlchaîneCorps HTML. Au moins l’un des deux est requis.
toNamechaîneNom affiché du destinataire.
fromNamechaîneNom affiché de l’expéditeur.
replyTochaîneAdresse de réponse, si différente.
categorychaîneotp, transactional (défaut), marketing.
curl https://api-sms.novae.ci/v1/emails \
  -H "Authorization: Bearer sk_live_…" \
  -H "Idempotency-Key: recu-48127" \
  -H "Content-Type: application/json" \
  -d '{
    "to":      "client@exemple.ci",
    "from":    "contact@votredomaine.ci",
    "subject": "Votre reçu N12345",
    "html":    "<p>Merci pour votre commande.</p>"
  }'

← 202 Accepted
{ "id": "13fb6a30-…", "status": "queued",
  "priceMilli": 5000 }

Le suivi se fait sur GET /v1/emails/{id}, avec les mêmes statuts que le SMS. Les rebonds et signalements pour spam remontent en undelivered : un vocabulaire unique pour les deux canaux.

Formats de numéro

Le destinataire est normalisé en E.164 avant tout traitement. Ces écritures sont équivalentes :

"0707123456"          // format national
"07 07 12 34 56"      // séparateurs ignorés
"+225 07 07 12 34 56" // international
"00225 0707123456"    // préfixe de sortie
                      // → tous : +2250707123456

Préfixes ivoiriens reconnus : 01 Moov, 05 MTN, 07 Orange, 25 et 27 fixe. Les numéros à 8 chiffres d’avant la migration 2021 sont refusés plutôt que facturés dans le vide.

Encodage et segments

Le segment est l’unité facturée. Sa taille dépend de l’alphabet utilisé, et un seul caractère hors table change tout le message.

EncodageSegment uniqueEn plusieurs segments
GSM7160 caractères153 par segment
UCS270 caractères67 par segment

Passent en GSM-7 sans surcoût : é è à ù ì ò Ç Ä Ö Ñ Ü ä ö ñ ü. Forcent l’UCS-2 : ç ê ë â î ï ô û et tout emoji. € ^ { } [ ] ~ | \ comptent double en GSM-7.

Le ç minuscule n’est pas dans la table GSM-7, alors que le Ç majuscule y est. « français » suffit à faire passer un message de 1 à 2 segments. Appelez preview avant d’envoyer.

Idempotence

Un délai réseau ne doit jamais coûter deux SMS. Chaque envoi porte une clé que vous choisissez ; nous mémorisons la réponse pendant 24 heures.

SituationRésultat
Même clé, même corps La réponse d’origine est rejouée, en-tête idempotent-replay: true. Rien n’est réenvoyé ni redébité.
Même clé, corps différent 409 idempotency_conflict — c’est une erreur d’intégration.
Envoi en échec La clé est libérée : corrigez et réessayez avec la même.

Utilisez un identifiant qui a du sens de votre côté — numéro de commande, identifiant de session. Un UUID aléatoire fonctionne, mais ne protège pas d’un double envoi par votre propre code.

Statuts

StatutSignification
queuedAccepté et facturé, en attente de routage.
routingSélection du fournisseur en cours.
submittedRemis au fournisseur.
sentAccepté par l’opérateur.
deliveredRemis au combiné. Terminal.
undeliveredRefusé par le réseau. Terminal, facturé.
expiredDurée de vie dépassée. Terminal, facturé.
rejectedRefusé par le fournisseur. Terminal, remboursé.
failedÉchec interne. Terminal, remboursé.

Ce qui a atteint le réseau est facturé, ce qui ne l’a jamais atteint est remboursé. C’est la pratique du marché, et elle est ici explicite.

Codes d’erreur

Toutes les erreurs partagent la même forme :

{ "error": { "code": "insufficient_funds",
            "message": "Solde insuffisant : 12 F disponible, 30 F requis" } }

Les codes sont notre taxonomie, jamais celle d’un fournisseur : changer de route ne casse pas votre intégration.

HTTPCodeCause et correctif
400invalid_requestCorps mal formé ou champ manquant.
400invalid_numberDestinataire non reconnu. Vérifier le format et le préfixe.
400message_too_longPlus de 1600 caractères.
400no_routeDestination non desservie. Réessayer n’y changera rien.
401unauthorizedClé absente, invalide ou révoquée.
402insufficient_fundsSolde inférieur au prix. Recharger.
403country_not_allowedDestination hors de votre liste blanche. Nous écrire pour l’étendre.
403organization_suspendedCompte suspendu ou fermé.
403sender_id_not_approvedExpéditeur non validé pour cet opérateur.
403suppressedDestinataire désabonné du marketing. Ne bloque jamais un OTP.
409idempotency_conflictClé réutilisée avec un corps différent.
429velocity_exceededTrop d’envois vers le même numéro. Garde-fou anti-fraude.
429spend_cap_exceededPlafond horaire ou journalier atteint.
429rate_limitedTrop de requêtes. Réessayer après une pause.
502provider_rejectedLe fournisseur a refusé. Remboursé.
503provider_unavailablePanne fournisseur. Réessayable.
500internal_errorErreur de notre côté. Nous la voyons.

Node, PHP, Python

Node

const res = await fetch('https://api-sms.novae.ci/v1/messages', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.NOVAE_SMS_KEY}`,
    'Idempotency-Key': commandeId,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    to: '0707123456', from: 'NOVAE',
    body: `Votre code est ${code}`, category: 'otp',
  }),
})
if (!res.ok) throw new Error((await res.json()).error.message)

PHP

// Laravel
use Illuminate\Support\Facades\Http;

$res = Http::withHeaders([
        'Authorization' => 'Bearer '.config('services.novae.key'),
        'Idempotency-Key' => $commandeId,
    ])->post('https://api-sms.novae.ci/v1/messages', [
        'to' => '0707123456',
        'from' => 'NOVAE',
        'body' => "Votre code est {$code}",
        'category' => 'otp',
    ]);

if ($res->failed()) {
    logger()->error('SMS refusé', $res->json('error'));
}

Python

import os, requests

res = requests.post(
    "https://api-sms.novae.ci/v1/messages",
    headers={
        "Authorization": f"Bearer {os.environ['NOVAE_SMS_KEY']}",
        "Idempotency-Key": commande_id,
    },
    json={"to": "0707123456", "from": "NOVAE",
          "body": f"Votre code est {code}", "category": "otp"},
    timeout=15,
)
res.raise_for_status()

Ce qui n’existe pas encore

Dit franchement, pour que vous dimensionniez votre intégration sur ce qui est réel :

  • Pas de webhook de statut. L’acheminement se suit en interrogeant GET /v1/messages/{id}. Les notifications sortantes sont en cours.
  • Pas d’envoi en masse. Un message par requête. Les campagnes et les listes de contacts viendront.
  • Pas de WhatsApp. C’est prévu, ce n’est pas ouvert.
  • Pas de SMS entrants. Ni réponse, ni gestion du STOP par l’API — la liste de désabonnement se gère depuis l’espace client.
  • Côte d’Ivoire uniquement pour l’instant, réseau Orange. L’extension aux autres opérateurs et à l’UEMOA est en cours.

Une question, un besoin non couvert ? Écrivez-nous.