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
  1. Démarrage en 5 étapes
  2. Authentification
  3. Créer une demande
  4. Modes approval et code
  5. Number matching
  6. Statuts
  7. L’objet request
  8. Toutes les routes
  9. Envoyer un message
  10. Événements
  11. Vérifier la signature
  12. Renvois
  13. Idempotence
  14. Sandbox et testeurs
  15. Erreurs
  16. Limites anti-abus

Démarrage en 5 étapes

Comptez cinq minutes, de la création du compte au premier webhook request.approved.

  1. 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.
  2. 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.
  3. 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.
  4. Réglez votre webhookDans Webhooks, indiquez une URL https et cochez les événements. Copiez le secret whsec_… : il sert à vérifier la signature.
  5. Envoyez votre première demandeAutorisez-la sur votre téléphone : request.approved arrive sur votre URL.
Première demande
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éfixeEnvironnementDisponible
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.

Tester sa clé — GET /v1/ping
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.

ChampTypeDescription
phone ou userE.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.
actionrequisslug[a-z0-9_]{2,40}, libre : login, password_reset, transaction, phone_change, device_login…
titlerequistexte2 à 60 caractères. Le titre de l’écran : « Connexion à Sahelab ».
messagetexte280 caractères au plus.
contextliste6 lignes au plus, { "label": ≤ 24, "value": ≤ 80 }, affichées telles quelles : navigateur, appareil, montant…
modeapproval | codeDéfaut approval. Voir les modes.
number_matchingbooléenDéfaut false, mode approval seulement. Voir number matching.
expires_insecondes30 à 900, défaut 300.
metadataobjet20 clés au plus, valeurs de 500 caractères au plus. Jamais montré à la personne, rendu dans l’objet et les webhooks.
localefr | enLangue de la notification.
POST /v1/requests
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é.

1. VousPOST /v1/requests avec "mode": "code"
2. La personneouvre la demande dans eNotif, lit « 482 913 », le tape sur votre page
3. VousPOST /v1/requests/{id}/verify avec { "code": "482913" }
POST /v1/requests/{id}/verify
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 en approved.
  • Code faux : 422 code_invalid avec attempts_remaining. Après 5 essais, la demande passe denied (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.

Dans la réponse
"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.

StatutSignification
pendingEnvoyée, en attente de la personne.
approvedAutorisée (signée par le téléphone), ou code vérifié.
deniedRefusée. denial : user, reported (« Ce n’est pas moi ») ou code_attempts.
expiredexpires_at est passé sans réponse. Elle ne s’approuve plus.
cancelledAnnulée par vous : POST /v1/requests/{id}/cancel.
failedDès la création. failure_reason : not_registered, no_trusted_device, recipient_unavailable.

L’objet request

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
}
  • user est l’identifiant de la personne propre à votre application : deux applications ne reçoivent jamais le même. null si not_registered.
  • phone est 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

RouteEffet
POST /requestsCréer une demande.
GET /requests/{id}Lire une demande — le polling de secours, si un webhook tarde.
GET /requestsListe, plus récent d’abord : status, limit (1–100, 20), starting_after. Rend { object: "list", data, has_more }.
POST /requests/{id}/cancelpending → cancelled, sinon 409 request_not_pending.
POST /requests/{id}/verifyMode code : vérifier le code tapé.
POST /messagesMessage 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 /eventsLes événements des 30 derniers jours : type, limit, starting_after.
GET /events/{id}Un événement.
POST /sandbox/requests/{id}/approveSandbox : simuler l’approbation. /deny pour le refus.
GET /pingTester 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.

ChampDescription
phone ou userLe destinataire.
titlerequis2 à 80 caractères.
body1 000 caractères au plus.
urlLien https vers un domaine autorisé de votre application (réglé dans la console). Bouton « Ouvrir ».
metadataComme pour une demande.
POST /v1/messages
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énementQuand
request.deliveredLa personne a ouvert la demande.
request.approvedAutorisée, ou code vérifié.
request.deniedRefusée (denial dit pourquoi).
request.expiredSans réponse à expires_at.
request.cancelledAnnulée par vous.
message.readUn message de la boîte a été lu.
device.changedLa personne a changé de téléphone de confiance. data.object = { id: "usr_…", object: "user", device_changed_at }. Appliquez votre politique de risque.
pingLe bouton « Envoyer un test » de la console.
Ce que reçoit votre serveur
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 :

  1. lisez t et v1 dans X-ENotif-Signature ;
  2. recalculez la signature et comparez-la en temps constant ;
  3. refusez si |maintenant − t| > 300 secondes ;
  4. ignorez un id d’événement déjà traité (répondez quand même 2xx).
<?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).

TentativeDélai après la précédente
21 minute
35 minutes
430 minutes
52 heures
66 heures
712 heures
824 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.
POST /v1/sandbox/requests/{id}/approve
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.

Réponse d’erreur
{
  "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"
  }
}
HTTPtypecode
400 / 422invalid_request_errorparameter_missing, parameter_invalid, phone_invalid, phone_or_user, context_invalid, metadata_invalid, number_matching_code, url_not_allowed, code_invalid (+ attempts_remaining)
401authentication_errorapi_key_missing, api_key_invalid, api_key_revoked
402quota_errorquota_exceeded — production seulement, quand les formules sont actives
403permission_errorapplication_suspended, account_not_approved, livemode_forbidden
404invalid_request_errorresource_missing
409invalid_request_errorrequest_not_pending, request_expired, request_not_code_mode, request_code_mode, code_not_revealed, idempotency_key_reused
429rate_limit_errorrate_limited — attendez Retry-After secondes

Limites anti-abus

Une application ne peut pas inonder quelqu’un de demandes. Valeurs par défaut :

LimiteValeur
Demandes en attente, par application et par personne3
Demandes par application et par personne10 par heure
Demandes par personne, toutes applications30 par heure
Messages de la boîte, par personne20 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.