Navigation de la documentation

Developer portal · API REST

Documentation & SDKs

Intégrez l’envoi de messages OTP par WhatsApp ou e-mail avec les exemples cURL, Laravel, Node.js et Python.

API REST
POST/api/otp/send

Envoie un message OTP par WhatsApp ou e-mail.

Voir le contrat

Aperçu#

Ce service expose un endpoint transactionnel pour envoyer un message WhatsApp ou un e-mail contenant typiquement un code OTP. Il ne génère ni ne vérifie de code lui-même — c'est votre application (le « client ») qui génère le code, le stocke, et vérifie ce que l'utilisateur saisit. Cette page documente l'API elle-même, puis montre comment l'intégrer entièrement dans une application Laravel cliente.

Authentification#

Chaque requête doit porter un header X-Api-Key avec une clé générée depuis le dashboard (section « Clés API ») du compte client. La clé n'est affichée qu'une seule fois à sa création — notez-la immédiatement. Seuls les comptes activés par l'administrateur peuvent appeler l'API.

Une clé donne accès à l'envoi avec le numéro personnel du client ou le pool de numéros partagés de l'administrateur (sélection aléatoire parmi les sessions connectées), selon le choix de l'utilisateur dans son dashboard. Traitez-la comme un secret : ne la commitez jamais dans un dépôt, passez-la par une variable d'environnement.

Référence de l'endpoint#

POST /api/otp/send

http
Headers:
  X-Api-Key: <votre clé>
  Content-Type: application/json

Body (JSON):
{
  "channel": "whatsapp",              // optionnel, "whatsapp" par défaut ou "email"
  "phone":   "33612345678",           // requis pour WhatsApp, chiffres uniquement, "+" optionnel
  "message": "Votre code est 123456"  // requis, texte libre, 4096 caractères max
}

Canal e-mail et configuration SMTP#

Pour envoyer un e-mail, transmettez channel: "email" et un destinataire email à la même route :

json
{
  "channel": "email",
  "email": "client@example.com",
  "message": "Votre code de vérification est 123456."
}

Le client API génère le code et fournit le texte du message ; l’API ne génère ni ne vérifie le code. Le compte utilise son SMTP personnel s’il est configuré dans son dashboard, sinon le SMTP global configuré par l’administrateur ou, à défaut, celui de Portainer. L’adresse destinataire est masquée dans l’historique et le contenu du message n’est jamais conservé.

Le SMTP global de secours se configure depuis le tableau de bord administrateur. Les identifiants enregistrés sont chiffrés avec la clé APP_KEY. Si la configuration du tableau de bord est désactivée, l’application utilise les variables d’environnement Portainer suivantes comme solution de repli :

env
MAIL_MAILER=smtp
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USERNAME=your-smtp-user
MAIL_PASSWORD=your-smtp-app-password
MAIL_FROM_ADDRESS=otp@example.com
MAIL_FROM_NAME="${APP_NAME}"

Après la mise à jour des variables, redémarrez le conteneur de l’application. Les mots de passe SMTP personnels et ceux configurés par l’administrateur sont chiffrés en base avec la clé APP_KEY ; ne la modifiez pas sans planifier la migration de ces secrets.

CodeSignification
200Message envoyé — réponse {"success": true, "id": "..."}
401Clé API manquante ou invalide
403Compte non vérifié, en attente ou suspendu
422Canal, destinataire ou message invalide (voir errors dans la réponse)
429Limite de requêtes (30/minute) ou quota mensuel du compte atteint
503Session WhatsApp indisponible ou SMTP absent/injoignable

Exemples SDK pour l’envoi d’un OTP

curl --request POST "${WHATSAPP_OTP_URL:-https://votre-domaine.example/api/otp/send}" \
  --header "X-Api-Key: ${WHATSAPP_OTP_KEY}" \
  --header "Content-Type: application/json" \
  --data '{"phone":"33612345678","message":"Votre code est 123456"}'

Limites & bonnes pratiques#

  • Rate limit : 30 requêtes/minute par défaut sur cet endpoint (routes/api.php de ce projet).
  • Quota mensuel : 100 tentatives acceptées par défaut et par compte client, ajustable par l'administrateur. Les envois WhatsApp et e-mail utilisent le même quota ; une tentative reste décomptée si le fournisseur échoue après son acceptation.
  • Profils WhatsApp : quatre profils clients maximum peuvent être actifs simultanément. L’administrateur peut gérer jusqu’à dix numéros partagés (session principale incluse) ; l’API choisit aléatoirement parmi ceux connectés.
  • Journalisation : les envois et événements de compte sont conservés 90 jours ; les messages OTP ne sont pas stockés et les numéros destinataires sont masqués.
  • Génération du code côté client : cette API transporte un texte libre — c'est à votre application de générer un code aléatoire, de le stocker (haché, jamais en clair) et de le faire expirer.
  • Expiration courte : 5 à 10 minutes est une durée raisonnable pour un OTP.
  • Limiter les tentatives de vérification : bloquez après quelques essais incorrects (ex. 5) pour empêcher le brute-force du code.
  • Code OTP : générez le code côté client, stockez-le haché et faites-le expirer ; l’API ne le vérifie pas.

Implémentation côté Laravel (de A à Z)#

Exemple complet, dans une application Laravel consommatrice de cette API (une autre application que celle-ci), pour un flux « envoyer un code → vérifier le code saisi par l'utilisateur ».

1. Configuration#

Dans le .env de l'application cliente :

env
WHATSAPP_OTP_URL=http://localhost:8090/api/otp/send
WHATSAPP_OTP_KEY=wotp_xxxxxxxx

Dans config/services.php :

php
'whatsapp_otp' => [
    'url' => env('WHATSAPP_OTP_URL'),
    'key' => env('WHATSAPP_OTP_KEY'),
],

2. Table des codes OTP#

bash
php artisan make:migration create_otp_codes_table
php
Schema::create('otp_codes', function (Blueprint $table) {
    $table->id();
    $table->string('phone');
    $table->string('code_hash');
    $table->unsignedTinyInteger('attempts')->default(0);
    $table->timestamp('expires_at');
    $table->timestamp('consumed_at')->nullable();
    $table->timestamps();

    $table->index('phone');
});

3. Modèle OtpCode#

bash
php artisan make:model OtpCode
php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class OtpCode extends Model
{
    protected $fillable = ['phone', 'code_hash', 'attempts', 'expires_at', 'consumed_at'];

    protected $casts = [
        'expires_at'  => 'datetime',
        'consumed_at' => 'datetime',
    ];
}

4. Service WhatsAppOtpService#

Génère un code, l'envoie via l'API, et vérifie ce que l'utilisateur saisit ensuite. Centraliser cette logique dans un service évite de la dupliquer dans plusieurs contrôleurs (inscription, connexion, changement de numéro, etc.).

php
namespace App\Services;

use App\Models\OtpCode;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;

class WhatsAppOtpService
{
    private const CODE_LENGTH = 6;
    private const EXPIRES_IN_MINUTES = 10;
    private const MAX_ATTEMPTS = 5;

    public function generateAndSend(string $phone): void
    {
        $code = (string) random_int(100000, 999999); // 6 chiffres

        OtpCode::create([
            'phone'      => $phone,
            'code_hash'  => Hash::make($code),
            'expires_at' => now()->addMinutes(self::EXPIRES_IN_MINUTES),
        ]);

        $response = Http::withHeaders([
            'X-Api-Key' => config('services.whatsapp_otp.key'),
        ])->post(config('services.whatsapp_otp.url'), [
            'phone'   => $phone,
            'message' => "Votre code de vérification est : {$code}. Il expire dans " . self::EXPIRES_IN_MINUTES . ' minutes.',
        ]);

        if ($response->failed()) {
            Log::warning('Échec envoi OTP WhatsApp', [
                'phone'  => $phone,
                'status' => $response->status(),
                'body'   => $response->json(),
            ]);

            throw new RuntimeException(
                $response->json('error') ?? 'Impossible d\'envoyer le code WhatsApp pour le moment.'
            );
        }
    }

    public function verify(string $phone, string $code): bool
    {
        $otp = OtpCode::where('phone', $phone)
            ->whereNull('consumed_at')
            ->where('expires_at', '>', now())
            ->latest('id')
            ->first();

        if (! $otp) {
            return false;
        }

        if ($otp->attempts >= self::MAX_ATTEMPTS) {
            return false;
        }

        $otp->increment('attempts');

        if (! Hash::check($code, $otp->code_hash)) {
            return false;
        }

        $otp->update(['consumed_at' => now()]);

        return true;
    }
}
random_int() est cryptographiquement sûr — ne remplacez pas par rand()/mt_rand(). Le code est haché avec Hash::make() (bcrypt) : même un accès à la base ne révèle pas le code en clair.

5. Contrôleur et routes#

php
namespace App\Http\Controllers;

use App\Services\WhatsAppOtpService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;

class OtpController extends Controller
{
    public function request(Request $request, WhatsAppOtpService $otp): JsonResponse
    {
        $validated = $request->validate([
            'phone' => ['required', 'string', 'regex:/^\+?[0-9]{8,15}$/'],
        ]);

        try {
            $otp->generateAndSend($validated['phone']);
        } catch (RuntimeException $e) {
            return response()->json(['success' => false, 'error' => $e->getMessage()], 503);
        }

        return response()->json(['success' => true]);
    }

    public function verify(Request $request, WhatsAppOtpService $otp): JsonResponse
    {
        $validated = $request->validate([
            'phone' => ['required', 'string'],
            'code'  => ['required', 'string', 'size:6'],
        ]);

        if (! $otp->verify($validated['phone'], $validated['code'])) {
            return response()->json(['success' => false, 'error' => 'Code invalide ou expiré.'], 422);
        }

        // Ici : marquer le numéro comme vérifié, connecter l'utilisateur, etc.

        return response()->json(['success' => true]);
    }
}
php
// routes/web.php ou routes/api.php de l'application cliente
use App\Http\Controllers\OtpController;

Route::post('/otp/request', [OtpController::class, 'request'])->middleware('throttle:5,1');
Route::post('/otp/verify', [OtpController::class, 'verify'])->middleware('throttle:10,1');
Le throttle ici protège votre application (contre le spam d'envoi ou le brute-force de vérification) — il s'ajoute à la limite de 30/min déjà appliquée par ce service WhatsApp OTP lui-même.

6. Formulaire Blade#

Exemple minimal en deux étapes (téléphone → code), sans JavaScript de build :

blade
<form id="request-form">
    <input type="text" name="phone" placeholder="+33612345678" required>
    <button type="submit">Recevoir le code</button>
</form>

<form id="verify-form" style="display:none">
    <input type="text" name="code" placeholder="123456" maxlength="6" required>
    <button type="submit">Vérifier</button>
</form>

<script>
const phoneForm = document.getElementById('request-form');
const codeForm = document.getElementById('verify-form');
let currentPhone = null;

phoneForm.addEventListener('submit', async (e) => {
    e.preventDefault();
    currentPhone = new FormData(phoneForm).get('phone');

    const res = await fetch('/otp/request', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
        },
        body: JSON.stringify({ phone: currentPhone }),
    });

    if (res.ok) {
        phoneForm.style.display = 'none';
        codeForm.style.display = 'block';
    }
});

codeForm.addEventListener('submit', async (e) => {
    e.preventDefault();
    const code = new FormData(codeForm).get('code');

    const res = await fetch('/otp/verify', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
        },
        body: JSON.stringify({ phone: currentPhone, code }),
    });

    if (res.ok) {
        window.location = '/dashboard'; // ou la suite de votre flux
    }
});
</script>

7. Tests#

Ne testez jamais contre le vrai service WhatsApp — mockez la façade Http :

php
use Illuminate\Support\Facades\Http;

public function test_it_sends_an_otp_code(): void
{
    Http::fake([
        '*/api/otp/send' => Http::response(['success' => true, 'id' => 'msg-1'], 200),
    ]);

    $this->postJson('/otp/request', ['phone' => '33612345678'])
        ->assertOk();

    $this->assertDatabaseCount('otp_codes', 1);
}

public function test_it_rejects_an_invalid_code(): void
{
    Http::fake(['*/api/otp/send' => Http::response(['success' => true], 200)]);
    $this->postJson('/otp/request', ['phone' => '33612345678']);

    $this->postJson('/otp/verify', ['phone' => '33612345678', 'code' => '000000'])
        ->assertStatus(422);
}

Dépannage#

SymptômeCause probable
503 permanentSession WhatsApp déconnectée — retournez sur le dashboard, un QR peut être en attente de scan.
401Clé API absente du header, révoquée, ou mal copiée (espace, retour à la ligne).
422 sur phoneLe numéro doit être uniquement des chiffres (8 à 15), avec un + optionnel en préfixe — pas d'espaces ni de tirets.
Le message n'arrive jamais côté destinataireLimitation connue des clients WhatsApp non officiels sur un tout premier contact — voir le README du projet.