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"}'
/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
| Champ | Type | Description |
|---|---|---|
| to | chaîne, requis | Destinataire. Formats acceptés au § Numéros. |
| from | chaîne, requis | Expéditeur affiché. 11 caractères maximum pour un alias. |
| body | chaîne, requis | Texte du message, 1600 caractères maximum. |
| category | chaî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.
/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ête | Description | |
|---|---|---|
| Authorization | requis | Bearer suivi de votre clé. |
| Idempotency-Key | requis | 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.
/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"
}
/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
| Champ | Type | Description |
|---|---|---|
| to | chaîne, requis | Adresse du destinataire. |
| from | chaîne, requis | Votre adresse d’expédition validée. |
| subject | chaîne, requis | 250 caractères maximum. |
| text | chaîne | Corps en texte brut. |
| html | chaîne | Corps HTML. Au moins l’un des deux est requis. |
| toName | chaîne | Nom affiché du destinataire. |
| fromName | chaîne | Nom affiché de l’expéditeur. |
| replyTo | chaîne | Adresse de réponse, si différente. |
| category | chaîne | otp, 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.
| Encodage | Segment unique | En plusieurs segments |
|---|---|---|
| GSM7 | 160 caractères | 153 par segment |
| UCS2 | 70 caractères | 67 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.
| Situation | Ré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
| Statut | Signification |
|---|---|
| queued | Accepté et facturé, en attente de routage. |
| routing | Sélection du fournisseur en cours. |
| submitted | Remis au fournisseur. |
| sent | Accepté par l’opérateur. |
| delivered | Remis au combiné. Terminal. |
| undelivered | Refusé par le réseau. Terminal, facturé. |
| expired | Durée de vie dépassée. Terminal, facturé. |
| rejected | Refusé 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.
| HTTP | Code | Cause et correctif |
|---|---|---|
| 400 | invalid_request | Corps mal formé ou champ manquant. |
| 400 | invalid_number | Destinataire non reconnu. Vérifier le format et le préfixe. |
| 400 | message_too_long | Plus de 1600 caractères. |
| 400 | no_route | Destination non desservie. Réessayer n’y changera rien. |
| 401 | unauthorized | Clé absente, invalide ou révoquée. |
| 402 | insufficient_funds | Solde inférieur au prix. Recharger. |
| 403 | country_not_allowed | Destination hors de votre liste blanche. Nous écrire pour l’étendre. |
| 403 | organization_suspended | Compte suspendu ou fermé. |
| 403 | sender_id_not_approved | Expéditeur non validé pour cet opérateur. |
| 403 | suppressed | Destinataire désabonné du marketing. Ne bloque jamais un OTP. |
| 409 | idempotency_conflict | Clé réutilisée avec un corps différent. |
| 429 | velocity_exceeded | Trop d’envois vers le même numéro. Garde-fou anti-fraude. |
| 429 | spend_cap_exceeded | Plafond horaire ou journalier atteint. |
| 429 | rate_limited | Trop de requêtes. Réessayer après une pause. |
| 502 | provider_rejected | Le fournisseur a refusé. Remboursé. |
| 503 | provider_unavailable | Panne fournisseur. Réessayable. |
| 500 | internal_error | Erreur 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.