Documentation de l’API
Envoyez une demande au téléphone de confiance d’une personne, elle confirme d’un geste, votre serveur reçoit la réponse en webhook signé. Une API REST, du JSON, une clé.
Sommaire
Démarrage en 5 étapes
Comptez cinq minutes, de la création du compte au premier webhook request.approved.
- Créez votre compte développeurSur la console, avec votre e-mail ou « Se connecter avec eNotif ». La sandbox est ouverte tout de suite ; la production s’ouvre quand l’équipe a accepté le compte.
- Créez une application et sa clé de testSon nom et son logo sont ceux que la personne verra sur son téléphone. La clé
sk_test_…n’est affichée qu’une fois : rangez-la dans vos secrets. - Ajoutez-vous comme testeurDans Testeurs, invitez votre @pseudo eNotif, puis acceptez l’invitation dans l’app (Profil › Tests). En sandbox, seules les personnes testeuses reçoivent les demandes.
- Réglez votre webhookDans Webhooks, indiquez une URL https et cochez les événements. Copiez le secret
whsec_…: il sert à vérifier la signature. - Envoyez votre première demandeAutorisez-la sur votre téléphone :
request.approvedarrive sur votre URL.
curl https://enotif.sahelab.com/v1/requests \
-H "Authorization: Bearer sk_test_…" \
-H "Content-Type: application/json" \
-d '{ "phone": "+22370000001", "action": "login", "title": "Connexion à Sahelab" }'
Authentification
Chaque appel porte votre clé secrète dans l’en-tête Authorization. L’environnement vient de la clé :
| Préfixe | Environnement | Disponible |
|---|---|---|
sk_test_ | Sandbox — livemode: false. Les demandes n’atteignent que vos testeurs. | Dès l’inscription |
sk_live_ | Production — livemode: true. | Après acceptation du compte |
Une clé n’est montrée qu’à sa création ; nous n’en gardons qu’une empreinte et ses 4 derniers caractères. Gardez-la côté serveur, jamais dans une application mobile ou une page web. Une clé exposée se révoque dans la console, effet immédiat.
curl https://enotif.sahelab.com/v1/ping -H "Authorization: Bearer sk_test_…"
# { "application": { "id": "app_…", "name": "Sahelab" }, "livemode": false }
Toutes les réponses sont en JSON, en snake_case. Les dates sont des horodatages Unix en secondes, les numéros au format E.164 (+22370000001).
Créer une demande
POST /v1/requests envoie une demande au téléphone de confiance de la personne. Elle voit le nom et le logo de votre application, le titre, le message et les lignes de contexte.
| Champ | Type | Description |
|---|---|---|
phone ou user | E.164 / usr_… | Le destinataire : son numéro, ou l’identifiant usr_… que vous avez reçu pour lui. L’un des deux, pas les deux. |
actionrequis | slug | [a-z0-9_]{2,40}, libre : login, password_reset, transaction, phone_change, device_login… |
titlerequis | texte | 2 à 60 caractères. Le titre de l’écran : « Connexion à Sahelab ». |
message | texte | 280 caractères au plus. |
context | liste | 6 lignes au plus, { "label": ≤ 24, "value": ≤ 80 }, affichées telles quelles : navigateur, appareil, montant… |
mode | approval | code | Défaut approval. Voir les modes. |
number_matching | booléen | Défaut false, mode approval seulement. Voir number matching. |
expires_in | secondes | 30 à 900, défaut 300. |
metadata | objet | 20 clés au plus, valeurs de 500 caractères au plus. Jamais montré à la personne, rendu dans l’objet et les webhooks. |
locale | fr | en | Langue de la notification. |
curl https://enotif.sahelab.com/v1/requests \
-H "Authorization: Bearer sk_test_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8f14e45f-ceea-467f-a8f6-0a3b1d2c9e71" \
-d '{
"phone": "+22370000001",
"action": "login",
"title": "Connexion à Sahelab",
"message": "Une connexion est demandée.",
"context": [
{ "label": "Navigateur", "value": "Firefox" },
{ "label": "Appareil", "value": "Linux" }
],
"number_matching": true,
"metadata": { "session": "abc" }
}'
Réponse 201 : l’objet request. Un destinataire injoignable donne aussi 201, avec status: "failed" : ce n’est pas une erreur, votre code de repli lit le statut.
Repli SMS. Si failure_reason vaut not_registered, ce numéro n’a pas eNotif : envoyez votre SMS habituel. Les autres raisons (no_trusted_device, recipient_unavailable) se traitent de la même façon.
Modes approval et code
Vous choisissez, demande par demande, comment la personne répond. Dans les deux cas, le résultat part en webhook signé.
approval — Autoriser / Refuser
Le mode par défaut. La personne touche Autoriser ou Refuser ; son téléphone signe la réponse. « Ce n’est pas moi » refuse et signale (denial: "reported").
code — un code à 6 chiffres
Pour un parcours où la personne tape un code chez vous. eNotif génère le code ; l’app l’affiche après signature de l’appareil ; la personne le tape sur votre page ; votre serveur le vérifie. Le code ne vous est jamais envoyé.
POST /v1/requests avec "mode": "code"POST /v1/requests/{id}/verify avec { "code": "482913" }curl https://enotif.sahelab.com/v1/requests/req_7Hq2kLmN3pQrS8tUvWx1Yz/verify \
-H "Authorization: Bearer sk_test_…" \
-H "Content-Type: application/json" \
-d '{ "code": "482913" }'
- Code juste :
200, l’objet request enapproved. - Code faux :
422 code_invalidavecattempts_remaining. Après 5 essais, la demande passedenied(denial: "code_attempts"). - Code pas encore affiché sur le téléphone :
409 code_not_revealed. Demande en mode approval :409 request_not_code_mode.
Number matching
Contre la fatigue d’approbation et l’hameçonnage : avec "number_matching": true, la réponse contient un nombre à deux chiffres. Affichez-le sur votre page (« Touchez 47 sur votre téléphone »). L’app propose trois nombres ; la personne doit toucher le bon.
"number_matching": { "number": 47 }Un mauvais nombre refuse la demande et la signale : c’est le signe qu’une autre page a déclenché la demande. Le champ n’apparaît que si vous l’avez demandé ; il ne s’utilise qu’en mode approval.
Statuts
Une demande naît pending et change d’état une seule fois.
| Statut | Signification |
|---|---|
pending | Envoyée, en attente de la personne. |
approved | Autorisée (signée par le téléphone), ou code vérifié. |
denied | Refusée. denial : user, reported (« Ce n’est pas moi ») ou code_attempts. |
expired | expires_at est passé sans réponse. Elle ne s’approuve plus. |
cancelled | Annulée par vous : POST /v1/requests/{id}/cancel. |
failed | Dès la création. failure_reason : not_registered, no_trusted_device, recipient_unavailable. |
L’objet request
{
"id": "req_7Hq2kLmN3pQrS8tUvWx1Yz",
"object": "request",
"livemode": false,
"user": "usr_Q4mZ9xK2pL7nR1sT",
"phone": "+22370000001",
"action": "login",
"mode": "approval",
"title": "Connexion à Sahelab",
"message": "Une connexion est demandée.",
"context": [
{ "label": "Navigateur", "value": "Firefox" },
{ "label": "Appareil", "value": "Linux" }
],
"number_matching": { "number": 47 },
"metadata": { "session": "abc" },
"status": "pending",
"failure_reason": null,
"delivered_at": null,
"decided_at": null,
"expires_at": 1791371100,
"created_at": 1791370800
}
userest l’identifiant de la personne propre à votre application : deux applications ne reçoivent jamais le même.nullsinot_registered.phoneest rendu tel que vous l’avez envoyé, normalisé en E.164.delivered_at: la personne a ouvert la demande.decided_at: elle a répondu.
Toutes les routes
Base : https://enotif.sahelab.com/v1
| Route | Effet |
|---|---|
POST /requests | Créer une demande. |
GET /requests/{id} | Lire une demande — le polling de secours, si un webhook tarde. |
GET /requests | Liste, plus récent d’abord : status, limit (1–100, 20), starting_after. Rend { object: "list", data, has_more }. |
POST /requests/{id}/cancel | pending → cancelled, sinon 409 request_not_pending. |
POST /requests/{id}/verify | Mode code : vérifier le code tapé. |
POST /messages | Message dans la boîte eNotif. |
GET /messages/{id} | Lire un message. |
GET /users/{usr} | { id, object: "user", linked_at, device_changed_at } — rien d’autre. |
GET /events | Les événements des 30 derniers jours : type, limit, starting_after. |
GET /events/{id} | Un événement. |
POST /sandbox/requests/{id}/approve | Sandbox : simuler l’approbation. /deny pour le refus. |
GET /ping | Tester sa clé. |
Envoyer un message dans la boîte
La boîte eNotif reçoit les messages transactionnels de vos applications : « Votre commande est prête », « Votre mot de passe a changé ». Jamais de prospection. Vous n’écrivez qu’aux personnes liées à votre application, c’est-à-dire qui ont déjà approuvé une de vos demandes.
| Champ | Description |
|---|---|
phone ou user | Le destinataire. |
titlerequis | 2 à 80 caractères. |
body | 1 000 caractères au plus. |
url | Lien https vers un domaine autorisé de votre application (réglé dans la console). Bouton « Ouvrir ». |
metadata | Comme pour une demande. |
curl https://enotif.sahelab.com/v1/messages \
-H "Authorization: Bearer sk_test_…" \
-H "Content-Type: application/json" \
-d '{
"user": "usr_Q4mZ9xK2pL7nR1sT",
"title": "Votre commande est prête",
"body": "Retrait au comptoir dès 14 h."
}'
# { "id": "msg_…", "object": "message", "user": "usr_…",
# "status": "sent", "failure_reason": null, "created_at": 1791370900 }
status: "failed" avec failure_reason : not_linked (aucune demande approuvée de votre application), not_registered, recipient_unavailable. Une personne qui a coupé votre application reçoit le message dans sa boîte, sans notification : vous lisez sent.
Webhooks
Le webhook est le canal principal : un point de terminaison par application et par environnement, réglé dans la console (URL https, liste d’événements). En sandbox, une URL http est acceptée pour un tunnel vers localhost.
| Événement | Quand |
|---|---|
request.delivered | La personne a ouvert la demande. |
request.approved | Autorisée, ou code vérifié. |
request.denied | Refusée (denial dit pourquoi). |
request.expired | Sans réponse à expires_at. |
request.cancelled | Annulée par vous. |
message.read | Un message de la boîte a été lu. |
device.changed | La personne a changé de téléphone de confiance. data.object = { id: "usr_…", object: "user", device_changed_at }. Appliquez votre politique de risque. |
ping | Le bouton « Envoyer un test » de la console. |
POST /enotif/webhook HTTP/1.1
Content-Type: application/json
User-Agent: eNotif-Webhooks/1.0
X-ENotif-Event: request.approved
X-ENotif-Event-Id: evt_3NfQ8rT2LmZx9KpW
X-ENotif-Timestamp: 1791370812
X-ENotif-Signature: t=1791370812,v1=5f2b…c9
{
"id": "evt_3NfQ8rT2LmZx9KpW",
"object": "event",
"type": "request.approved",
"livemode": false,
"created": 1791370812,
"data": { "object": { "id": "req_7Hq2kLmN3pQrS8tUvWx1Yz", "object": "request", "status": "approved", … } }
}
Répondez 2xx en moins de 10 secondes, puis traitez l’événement : un traitement long se met en file chez vous.
Vérifier la signature
v1 = hex(HMAC_SHA256(secret, t + "." + corps_brut))Où secret est votre secret whsec_… entier, préfixe compris, et corps_brut les octets reçus, avant tout décodage JSON. Pour chaque webhook :
- lisez
tetv1dansX-ENotif-Signature; - recalculez la signature et comparez-la en temps constant ;
- refusez si
|maintenant − t| > 300secondes ; - ignorez un
idd’événement déjà traité (répondez quand même2xx).
<?php
// The raw body, exactly as received — never json_encode(json_decode(...)).
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_ENOTIF_SIGNATURE'] ?? '';
$secret = getenv('ENOTIF_WEBHOOK_SECRET'); // whsec_…
function enotif_verify(
string $payload, string $header, string $secret, int $tolerance = 300
): bool {
$t = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't') $t = $value;
if ($key === 'v1' && $value !== '') $signatures[] = $value;
}
if ($t === null || !ctype_digit($t) || $signatures === []) return false;
if (abs(time() - (int) $t) > $tolerance) return false;
$expected = hash_hmac('sha256', $t . '.' . $payload, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) return true; // constant time
}
return false;
}
if (!enotif_verify($payload, $header, $secret)) {
http_response_code(400);
exit;
}
$event = json_decode($payload, true);
// Deduplicate: a retry carries the same id. A UNIQUE column makes it safe.
if (already_processed($event['id'])) {
http_response_code(200);
exit;
}
mark_processed($event['id']);
if ($event['type'] === 'request.approved') {
$request = $event['data']['object']; // $request['id'], $request['user'], $request['metadata']
// … sign the session in
}
http_response_code(200);
import crypto from 'node:crypto';
import express from 'express';
const secret = process.env.ENOTIF_WEBHOOK_SECRET; // whsec_…
const app = express();
function verify(rawBody, header, tolerance = 300) {
let t = null;
const signatures = [];
for (const part of (header || '').split(',')) {
const [key, value = ''] = part.trim().split('=', 2);
if (key === 't') t = value;
if (key === 'v1' && value) signatures.push(value);
}
if (!t || !/^\d+$/.test(t) || signatures.length === 0) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > tolerance) return false;
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(t + '.').update(rawBody).digest('hex'),
);
return signatures.some((s) => {
const given = Buffer.from(s);
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
// express.raw: the body stays a Buffer, byte for byte.
app.post('/enotif/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verify(req.body, req.get('X-ENotif-Signature'))) return res.sendStatus(400);
const event = JSON.parse(req.body.toString('utf8'));
// Deduplicate: a retry carries the same id.
if (await alreadyProcessed(event.id)) return res.sendStatus(200);
await markProcessed(event.id);
if (event.type === 'request.approved') {
const request = event.data.object; // request.id, request.user, request.metadata
// … sign the session in
}
res.sendStatus(200);
});
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["ENOTIF_WEBHOOK_SECRET"].encode() # whsec_…
app = Flask(__name__)
def verify(raw: bytes, header: str, tolerance: int = 300) -> bool:
t, signatures = None, []
for part in (header or "").split(","):
key, _, value = part.strip().partition("=")
if key == "t":
t = value
elif key == "v1" and value:
signatures.append(value)
if not t or not t.isdigit() or not signatures:
return False
if abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(SECRET, t.encode() + b"." + raw, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures) # constant time
@app.post("/enotif/webhook")
def enotif_webhook():
raw = request.get_data() # the raw bytes
if not verify(raw, request.headers.get("X-ENotif-Signature", "")):
abort(400)
event = json.loads(raw)
# Deduplicate: a retry carries the same id.
if already_processed(event["id"]):
return "", 200
mark_processed(event["id"])
if event["type"] == "request.approved":
req = event["data"]["object"] # req["id"], req["user"], req["metadata"]
# … sign the session in
return "", 200
Le secret se renouvelle dans la console (Webhooks › Renouveler). Un framework qui décode le JSON avant vous (body parser) casse la signature : lisez le corps brut.
Renvois
Sans réponse 2xx en 10 secondes, nous renvoyons l’événement, puis il passe failed. Vous pouvez toujours le renvoyer depuis la console (Webhooks › Renvoyer).
| Tentative | Délai après la précédente |
|---|---|
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 heures |
| 6 | 6 heures |
| 7 | 12 heures |
| 8 | 24 heures |
Le corps et l’id sont identiques à chaque renvoi ; seuls l’horodatage et donc la signature changent. Dédupliquez sur id (ou X-ENotif-Event-Id) : un webhook reçu deux fois ne connecte qu’une fois.
Idempotence
Ajoutez Idempotency-Key: <uuid> à chaque POST /v1/requests. Si le réseau coupe et que vous réessayez, la personne ne reçoit pas deux demandes.
- La clé vaut 24 heures.
- Même clé, même corps : la même réponse, la même demande.
- Même clé, autre corps :
409 idempotency_key_reused.
Sandbox et testeurs
Avec une clé sk_test_, tout se passe en sandbox : livemode: false partout, quotas jamais appliqués, webhooks de sandbox sur leur propre URL.
- Testeurs. Invitez des personnes eNotif par @pseudo (console › Testeurs). Une fois l’invitation acceptée dans l’app, elles reçoivent vos demandes de sandbox pour de vrai, avec un bandeau « Test ».
- Simulation. Vers tout autre numéro, la demande reste simulée : décidez-la depuis la console (Simuler) ou par l’API.
curl -X POST https://enotif.sahelab.com/v1/sandbox/requests/req_7Hq2kLmN3pQrS8tUvWx1Yz/approve \
-H "Authorization: Bearer sk_test_…"
# ou …/deny pour simuler un refus
Avec une clé sk_live_, ces routes répondent 403 livemode_forbidden.
Erreurs
Une seule forme. message est écrit pour un humain, en français par défaut, en anglais avec Accept-Language: en. Votre code lit type et code, qui ne changent pas.
{
"error": {
"type": "invalid_request_error",
"code": "phone_invalid",
"message": "Le numéro doit être au format E.164 (+22370000001).",
"param": "phone",
"doc_url": "https://enotif.sahelab.com/docs#errors"
}
}
| HTTP | type | code |
|---|---|---|
| 400 / 422 | invalid_request_error | parameter_missing, parameter_invalid, phone_invalid, phone_or_user, context_invalid, metadata_invalid, number_matching_code, url_not_allowed, code_invalid (+ attempts_remaining) |
| 401 | authentication_error | api_key_missing, api_key_invalid, api_key_revoked |
| 402 | quota_error | quota_exceeded — production seulement, quand les formules sont actives |
| 403 | permission_error | application_suspended, account_not_approved, livemode_forbidden |
| 404 | invalid_request_error | resource_missing |
| 409 | invalid_request_error | request_not_pending, request_expired, request_not_code_mode, request_code_mode, code_not_revealed, idempotency_key_reused |
| 429 | rate_limit_error | rate_limited — attendez Retry-After secondes |
Limites anti-abus
Une application ne peut pas inonder quelqu’un de demandes. Valeurs par défaut :
| Limite | Valeur |
|---|---|
| Demandes en attente, par application et par personne | 3 |
| Demandes par application et par personne | 10 par heure |
| Demandes par personne, toutes applications | 30 par heure |
| Messages de la boîte, par personne | 20 par jour |
| Essais d’un code (mode code) | 5 |
Des plafonds s’appliquent aussi par adresse IP et par clé. Au-delà : 429 rate_limited avec Retry-After. Trop de « Ce n’est pas moi » en 24 heures suspend l’application en production, en attendant la revue de l’équipe.
Une question ? Écrivez-nous.