Les webhooks SagaPass permettent à votre application de recevoir des notifications en temps réel quand un utilisateur confirme ou rejette une demande de vérification d'identité de type "confirmation".
â ïž IMPORTANT : Les webhooks sont UNIQUEMENT envoyĂ©s pour les challenges de type "confirmation" (HTTP 202). Pour les challenges de type "data_mismatch" (HTTP 409), aucun webhook n'est envoyĂ© car le partenaire reçoit dĂ©jĂ la rĂ©ponse immĂ©diatement.
| Type | HTTP | Quand ? | Webhook ? | |------|------|---------|-----------| | confirmation | 202 | Compte vĂ©rifiĂ© + donnĂ©es exactes | â OUI (user doit confirmer) | | data_mismatch | 409 | Variation de nom dĂ©tectĂ©e | â NON (rĂ©ponse immĂ©diate) |
Dans le cas data_mismatch :
â Pas besoin d'attendre une rĂ©ponse de l'utilisateur dans SagaPass
| MĂ©thode | DĂ©lai | Charge Serveur | ExpĂ©rience Utilisateur | |---------|-------|----------------|------------------------| | Webhook | ⥠InstantanĂ© (0-2s) | â Minimal | âââââ Excellent | | Polling (15s) | đ Jusqu'Ă 15s | â ĂlevĂ© (1 req/15s) | ââ Moyen | | Long Polling | đ¶ 2-5s | â ïž Moyen | ââââ Bon |
Lors de votre requĂȘte de vĂ©rification, ajoutez le champ webhook_url seulement si vous voulez ĂȘtre notifiĂ© en temps rĂ©el :
Payload (encrypté ou non) :
{
"encrypted": true,
"data": "...",
"webhook_url": "https://votresite.com/api/sagapass/webhook"
}
â ïž Note : Le webhook sera envoyĂ© UNIQUEMENT si la rĂ©ponse est HTTP 202 (pending_user_confirmation).
Ou en clair (dev uniquement) :
{
"encrypted": false,
"first_name": "Jean",
"last_name": "Dupont",
"date_of_birth": "1990-01-15",
"niu": "1234567890",
"email": "jean@example.com",
"webhook_url": "https://votresite.com/api/sagapass/webhook"
}
SagaPass retourne immédiatement avec HTTP 202 - Webhook sera envoyé :
{
"success": false,
"verified": false,
"status": "pending_user_confirmation",
"challenge_id": "uuid-challenge",
"expires_at": "2026-04-02T12:00:00Z",
"message": "L'utilisateur a été notifié et doit confirmer cette demande."
}
â Votre webhook recevra la rĂ©ponse quand l'utilisateur confirmera.
SagaPass retourne immédiatement avec HTTP 409 - Aucun webhook envoyé :
{
"success": false,
"verified": false,
"error": "data_mismatch",
"status": "data_not_matching",
"challenge_id": "uuid-challenge",
"message": "Les informations ne correspondent pas exactement..."
}
â Pas de webhook car l'utilisateur doit corriger ses infos chez vous, puis vous refaites une requĂȘte.
Quand l'utilisateur répond dans l'app SagaPass pour un challenge "confirmation" :
webhook_urlâ ïž Ces Ă©vĂ©nements sont UNIQUEMENT envoyĂ©s pour les challenges de type "confirmation" (HTTP 202).
Pour les challenges "data_mismatch" (HTTP 409), aucun webhook n'est envoyé car aucune action n'est attendue de l'utilisateur dans SagaPass.
verification.confirmedL'utilisateur a confirmé qu'il est bien à l'origine de la demande (challenge type: confirmation).
Payload reçu :
{
"event": "verification.confirmed",
"challenge_id": "9abc1234-5678-90ef-1234-567890abcdef",
"status": "confirmed",
"user_response": "{\"confirmed\":true,\"needs_correction\":false,\"timestamp\":\"2026-03-31T20:15:00Z\"}",
"challenge_type": "confirmation",
"verification": {
"reference": "ORDER-12345",
"niu": "1234567890",
"email": "jean@example.com",
"phone": "+50937001234"
},
"timestamp": "2026-03-31T20:15:00Z",
"responded_at": "2026-03-31T20:15:00Z",
"expires_at": "2026-04-02T12:00:00Z",
"user": {
"citizen_id": 42,
"verified": true
}
}
verification.rejectedL'utilisateur a indiqué que ce n'est pas lui (sécurité).
Payload reçu :
{
"event": "verification.rejected",
"challenge_id": "9abc1234-5678-90ef-1234-567890abcdef",
"status": "rejected",
"user_response": "{\"confirmed\":false,\"timestamp\":\"2026-03-31T20:15:00Z\"}",
"challenge_type": "confirmation",
"verification": {
"reference": "ORDER-12345",
"niu": "1234567890",
"email": "jean@example.com",
"phone": null
},
"timestamp": "2026-03-31T20:15:00Z",
"responded_at": "2026-03-31T20:15:00Z",
"expires_at": "2026-04-02T12:00:00Z",
"user": null
}
verification.expiredLe délai de 48h est écoulé sans réponse de l'utilisateur.
Payload reçu :
{
"event": "verification.expired",
"challenge_id": "9abc1234-5678-90ef-1234-567890abcdef",
"status": "expired",
"user_response": null,
"challenge_type": "confirmation",
"verification": {
"reference": "ORDER-12345",
"niu": "1234567890",
"email": "jean@example.com"
},
"timestamp": "2026-04-02T12:00:01Z",
"responded_at": null,
"expires_at": "2026-04-02T12:00:00Z",
"user": null
}
Chaque webhook inclut ces headers :
POST /api/sagapass/webhook HTTP/1.1
Host: votresite.com
Content-Type: application/json
X-Saga-Signature: sha256=abc123def456...
X-Saga-Event: verification.confirmed
X-Saga-Challenge-Id: 9abc1234-5678-90ef-1234-567890abcdef
User-Agent: SagaPass-Webhook/1.0
â ïž IMPORTANT : Toujours vĂ©rifier la signature pour Ă©viter les webhooks forgĂ©s.
La signature est calculée avec :
HMAC-SHA256(json_payload, APP_KEY)
Controller Webhook :
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class SagaPassWebhookController extends Controller
{
public function handle(Request $request)
{
// 1. Vérifier la signature HMAC
if (!$this->verifySignature($request)) {
Log::warning('SagaPass Webhook - Signature invalide', [
'ip' => $request->ip(),
]);
return response()->json(['error' => 'Invalid signature'], 401);
}
// 2. Récupérer les données
$event = $request->header('X-Saga-Event');
$challengeId = $request->header('X-Saga-Challenge-Id');
$payload = $request->all();
Log::info('SagaPass Webhook reçu', [
'event' => $event,
'challenge_id' => $challengeId,
]);
// 3. Traiter selon l'événement
switch ($event) {
case 'verification.confirmed':
$this->handleConfirmed($payload);
break;
case 'verification.rejected':
$this->handleRejected($payload);
break;
case 'verification.expired':
$this->handleExpired($payload);
break;
}
// 4. Répondre rapidement (200 OK)
return response()->json(['success' => true], 200);
}
private function verifySignature(Request $request): bool
{
$signature = $request->header('X-Saga-Signature');
if (!$signature || !str_starts_with($signature, 'sha256=')) {
return false;
}
$expectedHash = substr($signature, 7); // Remove "sha256="
$payload = $request->getContent();
$secret = config('sagapass.app_key'); // Votre APP_KEY SagaPass
$computedHash = hash_hmac('sha256', $payload, $secret);
return hash_equals($computedHash, $expectedHash);
}
private function handleConfirmed(array $payload)
{
$reference = $payload['verification']['reference'];
$citizenId = $payload['user']['citizen_id'];
// Mettre Ă jour votre commande/client
$order = Order::where('reference', $reference)->first();
if ($order) {
$order->update([
'sagapass_verified' => true,
'sagapass_citizen_id' => $citizenId,
'verification_status' => 'confirmed',
'verified_at' => now(),
]);
// Déclencher les actions métier (paiement, livraison, etc.)
$this->processVerifiedOrder($order);
Log::info('SagaPass Webhook - Commande vérifiée', [
'order_id' => $order->id,
'citizen_id' => $citizenId,
]);
}
}
private function handleRejected(array $payload)
{
$reference = $payload['verification']['reference'];
$order = Order::where('reference', $reference)->first();
if ($order) {
$order->update([
'verification_status' => 'rejected',
'verified_at' => now(),
]);
// Notifier l'équipe fraude
$this->notifySecurityTeam($order);
Log::warning('SagaPass Webhook - Vérification rejetée', [
'order_id' => $order->id,
]);
}
}
private function handleExpired(array $payload)
{
$reference = $payload['verification']['reference'];
$order = Order::where('reference', $reference)->first();
if ($order) {
$order->update([
'verification_status' => 'expired',
]);
// Relancer le client
$this->sendReminderEmail($order);
Log::info('SagaPass Webhook - Challenge expiré', [
'order_id' => $order->id,
]);
}
}
}
Route (routes/api.php) :
Route::post('/sagapass/webhook', [SagaPassWebhookController::class, 'handle'])
->name('sagapass.webhook');
â ïž IMPORTANT : DĂ©sactivez le CSRF pour cette route dans App\Http\Middleware\VerifyCsrfToken.php :
protected $except = [
'/api/sagapass/webhook',
];
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
app.post('/api/sagapass/webhook', (req, res) => {
// 1. Vérifier la signature
const signature = req.headers['x-saga-signature'];
const payload = JSON.stringify(req.body);
const secret = process.env.SAGAPASS_APP_KEY;
const expectedHash = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
if (signature !== expectedHash) {
console.log('â Signature invalide');
return res.status(401).json({ error: 'Invalid signature' });
}
// 2. Traiter l'événement
const event = req.headers['x-saga-event'];
const { verification, user, status } = req.body;
console.log(`â
Webhook reçu: ${event}`, verification.reference);
if (event === 'verification.confirmed') {
// Mettre Ă jour votre DB
updateOrder(verification.reference, {
verified: true,
citizen_id: user.citizen_id,
status: 'confirmed'
});
}
// 3. Répondre immédiatement
res.json({ success: true });
});
app.listen(3000, () => console.log('Webhook listener démarré'));
import hmac
import hashlib
import json
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.conf import settings
import logging
logger = logging.getLogger(__name__)
@csrf_exempt
def sagapass_webhook(request):
if request.method != 'POST':
return JsonResponse({'error': 'Method not allowed'}, status=405)
# 1. Vérifier la signature
signature = request.headers.get('X-Saga-Signature', '')
if not verify_signature(request.body, signature):
logger.warning('SagaPass Webhook - Signature invalide')
return JsonResponse({'error': 'Invalid signature'}, status=401)
# 2. Parser les données
payload = json.loads(request.body)
event = request.headers.get('X-Saga-Event')
challenge_id = request.headers.get('X-Saga-Challenge-Id')
logger.info(f'SagaPass Webhook reçu: {event}', extra={
'challenge_id': challenge_id,
'reference': payload['verification']['reference']
})
# 3. Traiter selon l'événement
if event == 'verification.confirmed':
handle_confirmed(payload)
elif event == 'verification.rejected':
handle_rejected(payload)
elif event == 'verification.expired':
handle_expired(payload)
# 4. Répondre rapidement
return JsonResponse({'success': True})
def verify_signature(payload, signature):
if not signature.startswith('sha256='):
return False
expected_hash = signature[7:]
secret = settings.SAGAPASS_APP_KEY
computed_hash = hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(computed_hash, expected_hash)
def handle_confirmed(payload):
reference = payload['verification']['reference']
citizen_id = payload['user']['citizen_id']
# Mettre Ă jour votre modĂšle
from orders.models import Order
try:
order = Order.objects.get(reference=reference)
order.sagapass_verified = True
order.citizen_id = citizen_id
order.verification_status = 'confirmed'
order.save()
logger.info(f'Commande {order.id} vérifiée avec succÚs')
except Order.DoesNotExist:
logger.error(f'Commande {reference} introuvable')
Exemple de payload test (simulation) :
curl -X POST https://votresite.com/api/sagapass/webhook \
-H "Content-Type: application/json" \
-H "X-Saga-Event: verification.confirmed" \
-H "X-Saga-Challenge-Id: test-challenge-123" \
-H "X-Saga-Signature: sha256=VOTRE_SIGNATURE_CALCULEE" \
-d '{
"event": "verification.confirmed",
"challenge_id": "test-123",
"status": "confirmed",
"verification": {
"reference": "TEST-ORDER",
"niu": "1234567890"
},
"user": {
"citizen_id": 99,
"verified": true
}
}'
Les webhooks sont loggés dans storage/logs/laravel.log :
[2026-03-31 20:15:00] local.INFO: NotifyPartnerWebhook - Webhook envoyé avec succÚs
{"challenge_id": "9abc1234", "event": "verification.confirmed", "webhook_url": "votresite.com/api/sagapass/webhook", "status_code": 200, "attempt": 1}
| Code | Signification | Action SagaPass | |------|---------------|-----------------| | 200-299 | â SuccĂšs | Pas de retry | | 4xx | â Erreur client | Pas de retry (webhook mal configurĂ©) | | 5xx | â ïž Erreur serveur | 3 tentatives de retry (30s, 2min, 5min) | | Timeout | â±ïž Pas de rĂ©ponse | 3 tentatives de retry |
Si votre webhook est temporairement down, vous pouvez toujours utiliser le polling :
// Polling de fallback (à implémenter dans votre frontend)
async function pollChallengeStatus(challengeId) {
const interval = setInterval(async () => {
const response = await fetch(`/api/check-sagapass-status/${challengeId}`);
const data = await response.json();
if (data.status === 'confirmed' || data.status === 'rejected') {
clearInterval(interval);
handleVerificationResult(data);
}
}, 15000); // Check chaque 15 secondes
// Timeout aprĂšs 10 minutes
setTimeout(() => clearInterval(interval), 600000);
}
SagaPass utilise une tùche planifiée Laravel pour nettoyer automatiquement les challenges expirés et envoyer les webhooks verification.expired.
Configuration du scheduler (dans bootstrap/app.php) :
->withSchedule(function (Schedule $schedule): void {
$schedule->command('challenges:clean-expired')
->hourly() // S'exécute toutes les heures
->withoutOverlapping() // Ăvite les exĂ©cutions simultanĂ©es
->onOneServer(); // En production multi-serveur
})
La commande challenges:clean-expired :
pending oĂč expires_at < maintenantexpiredverification.expired si :
challenge_type = 'confirmation' (seulement)webhook_url est configuréTest manuel :
# Mode dry-run (simulation sans modification)
php artisan challenges:clean-expired --dry-run
# Exécution réelle
php artisan challenges:clean-expired
| Fréquence | Commande | Impact |
|-----------|----------|--------|
| Toutes les heures | ->hourly() | â
Recommandé (délai max 1h) |
| Toutes les 30 min | ->everyThirtyMinutes() | ⥠Plus réactif |
| Une fois par jour | ->daily() | â ïž Pas assez frĂ©quent |
Note : Avec ->hourly(), un challenge expirant à 14h05 sera traité au plus tard à 15h00.
Pour que les tùches planifiées s'exécutent, ajoutez à votre cron (Linux/Mac) ou Task Scheduler (Windows) :
Linux/Mac (crontab) :
* * * * * cd /path-to-sagapass && php artisan schedule:run >> /dev/null 2>&1
Windows Task Scheduler :
Action: Start a program
Program: C:\PHP\php.exe
Arguments: artisan schedule:run
Start in: C:\path-to-sagapass\saga-id
Trigger: Repeat every 1 minute
Le scheduler Laravel s'occupe ensuite d'exécuter challenges:clean-expired à l'heure prévue.
â
Configuration : Ajoutez webhook_url lors de la vérification
â
Sécurité : Vérifiez toujours la signature HMAC
â
Performance : Répondez rapidement (< 5 secondes)
â
Idempotence : Traitez les retries de maniĂšre idempotente
â
Fallback : Gardez un systĂšme de polling en backup
En cas de problĂšme :
Délai de réponse : Sous 24h (jours ouvrables)