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/otp/send
Envoie un message OTP par WhatsApp ou e-mail.
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.
Référence de l'endpoint#
POST /api/otp/send
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 :
{
"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 :
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.
| Code | Signification |
|---|---|
200 | Message envoyé — réponse {"success": true, "id": "..."} |
401 | Clé API manquante ou invalide |
403 | Compte non vérifié, en attente ou suspendu |
422 | Canal, destinataire ou message invalide (voir errors dans la réponse) |
429 | Limite de requêtes (30/minute) ou quota mensuel du compte atteint |
503 | Session 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"}'
$response = Http::withHeaders([
'X-Api-Key' => config('services.whatsapp_otp.key'),
])->post(config('services.whatsapp_otp.url'), [
'phone' => '33612345678',
'message' => 'Votre code est 123456',
]);
const response = await fetch(process.env.WHATSAPP_OTP_URL, {
method: 'POST',
headers: {
'X-Api-Key': process.env.WHATSAPP_OTP_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
phone: '33612345678',
message: 'Votre code est 123456',
}),
});
response = requests.post(
os.environ["WHATSAPP_OTP_URL"],
headers={"X-Api-Key": os.environ["WHATSAPP_OTP_KEY"]},
json={"phone": "33612345678", "message": "Votre code est 123456"},
)
Limites & bonnes pratiques#
- Rate limit : 30 requêtes/minute par défaut sur cet endpoint (
routes/api.phpde 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 :
WHATSAPP_OTP_URL=http://localhost:8090/api/otp/send
WHATSAPP_OTP_KEY=wotp_xxxxxxxx
Dans config/services.php :
'whatsapp_otp' => [
'url' => env('WHATSAPP_OTP_URL'),
'key' => env('WHATSAPP_OTP_KEY'),
],
2. Table des codes OTP#
php artisan make:migration create_otp_codes_table
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#
php artisan make:model OtpCode
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.).
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#
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]);
}
}
// 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');
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 :
<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 :
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ôme | Cause probable |
|---|---|
503 permanent | Session WhatsApp déconnectée — retournez sur le dashboard, un QR peut être en attente de scan. |
401 | Clé API absente du header, révoquée, ou mal copiée (espace, retour à la ligne). |
422 sur phone | Le 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é destinataire | Limitation connue des clients WhatsApp non officiels sur un tout premier contact — voir le README du projet. |