Webhooks Scell.io
Cette documentation decrit comment recevoir et verifier les notifications webhook de Scell.io.
Table des matieres
- Vue d'ensemble
- Evenements disponibles
- Format du payload
- Headers HTTP
- Verification de la signature
- Exemples de code
- Bonnes pratiques
- Gestion des erreurs
Vue d'ensemble
Les webhooks permettent a votre application de recevoir des notifications en temps reel lorsque des evenements se produisent sur votre compte Scell.io (facture validee, signature completee, etc.).
Fonctionnement :
- Vous configurez une URL de webhook dans votre dashboard ou via l'API
- Scell.io vous fournit un secret unique (format :
whsec_xxxx...) - A chaque evenement, Scell.io envoie une requete POST signee a votre URL
- Votre serveur verifie la signature et traite l'evenement
Evenements disponibles
Facturation electronique (sortante)
| Evenement | Description |
|---|---|
invoice.created | Une facture a ete creee |
invoice.validated | La facture a ete validee (Factur-X/UBL genere) |
invoice.transmitted | La facture a ete transmise au PDP |
invoice.accepted | La facture a ete acceptee par le destinataire |
invoice.rejected | La facture a ete refusee |
invoice.error | Erreur lors du traitement de la facture |
Facturation electronique (entrante)
| Evenement | Description |
|---|---|
invoice.incoming.received | Nouvelle facture recue d'un fournisseur |
invoice.incoming.validated | La facture entrante a ete validee techniquement |
invoice.incoming.accepted | La facture entrante a ete acceptee |
invoice.incoming.rejected | La facture entrante a ete rejetee |
invoice.incoming.disputed | La facture entrante a ete contestee |
invoice.incoming.paid | La facture entrante a ete marquee comme payee |
Signature electronique
| Evenement | Description |
|---|---|
signature.created | Une demande de signature a ete creee |
signature.waiting | En attente de signature |
signature.signed | Un signataire a signe |
signature.completed | Tous les signataires ont signe |
signature.refused | La signature a ete refusee |
signature.expired | La demande de signature a expire |
signature.error | Erreur lors du processus de signature |
Compte
| Evenement | Description |
|---|---|
balance.low | Solde de credits faible (seuil d'alerte) |
balance.critical | Solde de credits critique |
Onboarding B2B (Tenant partenaire)
| Evenement | Description |
|---|---|
onboarding.started | Une session d'onboarding a ete demarree |
onboarding.step_completed | Une etape d'onboarding a ete completee |
onboarding.completed | L'onboarding a ete finalise avec succes |
onboarding.failed | L'onboarding a echoue ou a ete abandonne |
Credit Notes (Avoirs)
| Evenement | Description |
|---|---|
credit_note.created | Un avoir a ete cree |
credit_note.sent | Un avoir a ete valide et envoye |
credit_note.cancelled | Un avoir a ete annule |
Format du payload
Chaque webhook est envoye en POST avec un corps JSON structure ainsi :
{
"event": "invoice.validated",
"webhook_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-01-24T10:30:00+00:00",
"company_id": "123e4567-e89b-12d3-a456-426614174000",
"data": {
"invoice": {
"id": "inv_abc123",
"company_id": "123e4567-e89b-12d3-a456-426614174000",
"invoice_number": "FAC-2026-001",
"status": "validated",
"total_amount": 1200.00,
"currency": "EUR",
"customer": {
"name": "Client SARL",
"siren": "123456789"
},
"created_at": "2026-01-24T09:00:00+00:00",
"validated_at": "2026-01-24T10:30:00+00:00"
}
}
}Exemple de payload pour une facture entrante
{
"event": "invoice.incoming.received",
"webhook_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-01-24T14:15:00+00:00",
"company_id": "123e4567-e89b-12d3-a456-426614174000",
"data": {
"incoming_invoice": {
"id": "inc_xyz789",
"invoice_number": "FOURN-2026-0042",
"status": "pending",
"direction": "incoming",
"seller": {
"name": "Fournisseur SARL",
"siret": "98765432109876",
"vat_number": "FR12987654321"
},
"buyer": {
"name": "Votre Entreprise SAS",
"siret": "12345678901234"
},
"total_ht": 1000.00,
"total_tva": 200.00,
"total_ttc": 1200.00,
"currency": "EUR",
"invoice_date": "2026-01-20",
"due_date": "2026-02-20",
"received_at": "2026-01-24T14:15:00+00:00"
}
}
}Exemple de payload pour une facture entrante acceptee
{
"event": "invoice.incoming.accepted",
"webhook_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-01-25T09:30:00+00:00",
"company_id": "123e4567-e89b-12d3-a456-426614174000",
"data": {
"incoming_invoice": {
"id": "inc_xyz789",
"invoice_number": "FOURN-2026-0042",
"status": "accepted",
"payment_date": "2026-02-15",
"acceptance_note": "Facture conforme aux prestations",
"accepted_at": "2026-01-25T09:30:00+00:00"
}
}
}Exemple de payload pour une facture entrante contestee
{
"event": "invoice.incoming.disputed",
"webhook_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-01-25T10:00:00+00:00",
"company_id": "123e4567-e89b-12d3-a456-426614174000",
"data": {
"incoming_invoice": {
"id": "inc_xyz789",
"invoice_number": "FOURN-2026-0042",
"status": "disputed",
"dispute_type": "amount",
"dispute_reason": "Montant incorrect, prestation de 2h facturee pour 4h",
"expected_amount": 500.00,
"disputed_at": "2026-01-25T10:00:00+00:00"
}
}
}Exemple de payload pour une facture entrante payee
{
"event": "invoice.incoming.paid",
"webhook_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-01-24T14:30:00+00:00",
"company_id": "123e4567-e89b-12d3-a456-426614174000",
"data": {
"incoming_invoice": {
"id": "inc_xyz789",
"invoice_number": "FOURN-2026-0042",
"status": "paid",
"seller_name": "Fournisseur SAS",
"seller_siret": "12345678901234",
"total_ttc": 1200.00,
"currency": "EUR",
"paid_at": "2026-01-24T14:30:00+00:00",
"payment_reference": "VIR-2026-0124"
}
}
}Exemple de payload pour onboarding.started
{
"event": "onboarding.started",
"webhook_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-01-25T09:00:00+00:00",
"parent_tenant_id": "parent_tenant_abc123",
"data": {
"session": {
"id": "onb_xyz789",
"status": "pending",
"mode": "embedded",
"locale": "fr",
"created_at": "2026-01-25T09:00:00+00:00",
"expires_at": "2026-01-25T10:00:00+00:00"
}
}
}Exemple de payload pour onboarding.step_completed
{
"event": "onboarding.step_completed",
"webhook_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-01-25T09:15:00+00:00",
"parent_tenant_id": "parent_tenant_abc123",
"data": {
"session": {
"id": "onb_xyz789",
"status": "in_progress",
"current_step": "company_info",
"completed_steps": ["siret_verification"],
"progress_percent": 25
},
"step": {
"name": "siret_verification",
"completed_at": "2026-01-25T09:15:00+00:00",
"data": {
"siret": "12345678901234",
"company_name": "Ma Societe SAS",
"verified": true
}
}
}
}Exemple de payload pour onboarding.completed
{
"event": "onboarding.completed",
"webhook_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-01-25T09:30:00+00:00",
"parent_tenant_id": "parent_tenant_abc123",
"data": {
"session": {
"id": "onb_xyz789",
"status": "completed",
"completed_at": "2026-01-25T09:30:00+00:00"
},
"tenant": {
"id": "tenant_def456",
"name": "Ma Societe SAS",
"siret": "12345678901234",
"email": "contact@masociete.fr"
},
"credentials": {
"api_key_prefix": "sk_live_xxx",
"webhook_secret_prefix": "whsec_xxx"
}
}
}Exemple de payload pour onboarding.failed
{
"event": "onboarding.failed",
"webhook_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-01-25T10:00:00+00:00",
"parent_tenant_id": "parent_tenant_abc123",
"data": {
"session": {
"id": "onb_xyz789",
"status": "failed",
"failed_at": "2026-01-25T10:00:00+00:00"
},
"error": {
"code": "session_expired",
"message": "La session d'onboarding a expire",
"step": "kyb_documents"
}
}
}Exemple de payload pour credit_note.created
{
"event": "credit_note.created",
"webhook_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-01-25T10:30:00+00:00",
"company_id": "123e4567-e89b-12d3-a456-426614174000",
"data": {
"credit_note": {
"id": "cn_abc123",
"credit_note_number": "AV-202601-00001",
"invoice_id": "inv_xyz789",
"invoice_number": "FA-202601-00001",
"status": "draft",
"type": "partial",
"reason": "Remboursement partiel",
"subtotal": 100.00,
"tax_amount": 20.00,
"total": 120.00,
"currency": "EUR",
"buyer": {
"name": "Client SARL",
"siret": "12345678901234"
},
"seller": {
"name": "Vendeur SAS",
"siret": "98765432109876"
},
"tenant_id": "123e4567-e89b-12d3-a456-426614174000",
"sub_tenant_id": "456e7890-e12b-34d5-a678-901234567890",
"is_sandbox": false,
"created_at": "2026-01-25T10:30:00+00:00"
}
}
}Exemple de payload pour credit_note.sent
{
"event": "credit_note.sent",
"webhook_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-01-25T10:35:00+00:00",
"company_id": "123e4567-e89b-12d3-a456-426614174000",
"data": {
"credit_note": {
"id": "cn_abc123",
"credit_note_number": "AV-202601-00001",
"status": "sent",
"sent_at": "2026-01-25T10:35:00+00:00",
"pdf_url": "https://api.scell.io/api/v1/tenant/credit-notes/cn_abc123/download"
}
}
}Exemple de payload pour credit_note.cancelled
{
"event": "credit_note.cancelled",
"webhook_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-01-25T11:00:00+00:00",
"company_id": "123e4567-e89b-12d3-a456-426614174000",
"data": {
"credit_note": {
"id": "cn_abc123",
"credit_note_number": "AV-202601-00001",
"status": "cancelled",
"cancellation_reason": "Avoir cree par erreur",
"cancelled_at": "2026-01-25T11:00:00+00:00"
}
}
}Champs communs
| Champ | Type | Description |
|---|---|---|
event | string | Type d'evenement (ex: invoice.validated) |
webhook_id | uuid | Identifiant unique du webhook configure |
timestamp | ISO 8601 | Date/heure de l'evenement |
company_id | uuid | Identifiant de l'entreprise concernee |
data | object | Donnees specifiques a l'evenement |
Headers HTTP
Chaque requete webhook inclut les headers suivants :
| Header | Description | Exemple |
|---|---|---|
Content-Type | Type de contenu | application/json |
X-Scell-Signature | Signature HMAC | t=1706090400,v1=abc123... |
X-Scell-Event | Type d'evenement | invoice.validated |
X-Scell-Delivery | ID unique de livraison | d4e5f6... |
User-Agent | Agent utilisateur | Scell.io-Webhook/1.0 |
Verification de la signature
IMPORTANT : Vous devez TOUJOURS verifier la signature avant de traiter un webhook. Cela garantit que la requete provient bien de Scell.io et n'a pas ete alteree.
Algorithme
Extraire le timestamp et la signature du header
X-Scell-Signature- Format :
t={timestamp},v1={signature}
- Format :
Construire le payload a signer
- Concatener :
{timestamp}.{body_json}
- Concatener :
Calculer le HMAC-SHA256
- Cle : votre secret webhook (
whsec_...) - Message : le payload construit a l'etape 2
- Cle : votre secret webhook (
Comparer les signatures de maniere securisee (timing-safe)
Verifier le timestamp (protection contre les replay attacks)
- Rejeter si l'evenement date de plus de 5 minutes
Pseudocode
signature_header = "t=1706090400,v1=abc123def456..."
body = '{"event":"invoice.validated",...}'
secret = "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# Etape 1: Parser le header
timestamp = 1706090400
signature_received = "abc123def456..."
# Etape 2: Construire le payload
signed_payload = "1706090400.{body}"
# Etape 3: Calculer HMAC
expected_signature = HMAC-SHA256(signed_payload, secret)
# Etape 4: Comparer (timing-safe)
if not timing_safe_equals(signature_received, expected_signature):
reject("Invalid signature")
# Etape 5: Verifier le timestamp
if abs(current_time - timestamp) > 300:
reject("Timestamp too old")Exemples de code
PHP / Laravel
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class WebhookController extends Controller
{
/**
* Secret du webhook (a stocker dans .env)
*/
private string $webhookSecret;
public function __construct()
{
$this->webhookSecret = config('services.scell.webhook_secret');
}
/**
* Point d'entree du webhook Scell.io
*/
public function handle(Request $request): Response
{
// 1. Recuperer le header de signature
$signatureHeader = $request->header('X-Scell-Signature');
if (!$signatureHeader) {
return response('Missing signature header', 400);
}
// 2. Extraire timestamp et signature
$elements = $this->parseSignatureHeader($signatureHeader);
if (!$elements) {
return response('Invalid signature format', 400);
}
$timestamp = $elements['timestamp'];
$signature = $elements['signature'];
// 3. Verifier le timestamp (protection replay attacks)
if (abs(time() - $timestamp) > 300) {
return response('Timestamp expired', 403);
}
// 4. Reconstruire et verifier la signature
$payload = $request->getContent();
$expectedSignature = $this->computeSignature($timestamp, $payload);
if (!hash_equals($expectedSignature, $signature)) {
return response('Invalid signature', 403);
}
// 5. Traiter l'evenement
$event = $request->input('event');
$data = $request->input('data');
try {
$this->processEvent($event, $data);
return response('OK', 200);
} catch (\Exception $e) {
report($e);
return response('Processing error', 500);
}
}
/**
* Parser le header de signature
*/
private function parseSignatureHeader(string $header): ?array
{
$parts = explode(',', $header);
$elements = [];
foreach ($parts as $part) {
$keyValue = explode('=', $part, 2);
if (count($keyValue) === 2) {
$elements[$keyValue[0]] = $keyValue[1];
}
}
if (!isset($elements['t']) || !isset($elements['v1'])) {
return null;
}
return [
'timestamp' => (int) $elements['t'],
'signature' => $elements['v1'],
];
}
/**
* Calculer la signature attendue
*/
private function computeSignature(int $timestamp, string $payload): string
{
$signedPayload = "{$timestamp}.{$payload}";
return hash_hmac('sha256', $signedPayload, $this->webhookSecret);
}
/**
* Traiter l'evenement
*/
private function processEvent(string $event, array $data): void
{
match ($event) {
'invoice.validated' => $this->handleInvoiceValidated($data),
'invoice.rejected' => $this->handleInvoiceRejected($data),
'signature.completed' => $this->handleSignatureCompleted($data),
'signature.refused' => $this->handleSignatureRefused($data),
'balance.low' => $this->handleBalanceLow($data),
default => null, // Ignorer les evenements non geres
};
}
private function handleInvoiceValidated(array $data): void
{
// Votre logique metier
$invoiceId = $data['invoice']['id'];
logger()->info("Invoice validated: {$invoiceId}");
}
private function handleInvoiceRejected(array $data): void
{
// Votre logique metier
}
private function handleSignatureCompleted(array $data): void
{
// Votre logique metier
}
private function handleSignatureRefused(array $data): void
{
// Votre logique metier
}
private function handleBalanceLow(array $data): void
{
// Envoyer une alerte
}
}Route (routes/web.php) :
Route::post('/webhooks/scell', [WebhookController::class, 'handle'])
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);Configuration (.env) :
SCELL_WEBHOOK_SECRET=whsec_votre_secret_iciNote : Le secret webhook (
whsec_*) est unique par webhook. Il n'y a pas de secret webhook global. Chaque webhook configuré dans le dashboard a son propre secret, généré à la création. Si vous avez plusieurs webhooks, chacun a unSCELL_WEBHOOK_SECRETdistinct.
Configuration (config/services.php) :
'scell' => [
'webhook_secret' => env('SCELL_WEBHOOK_SECRET'),
],JavaScript / Node.js (Express)
const express = require('express');
const crypto = require('crypto');
const app = express();
// IMPORTANT: utiliser raw body pour la verification de signature
app.use('/webhooks/scell', express.raw({ type: 'application/json' }));
const WEBHOOK_SECRET = process.env.SCELL_WEBHOOK_SECRET;
const TIMESTAMP_TOLERANCE = 300; // 5 minutes
/**
* Verifier la signature du webhook
*/
function verifyWebhookSignature(signatureHeader, payload) {
if (!signatureHeader) {
throw new Error('Missing signature header');
}
// Parser le header: t=timestamp,v1=signature
const parts = {};
signatureHeader.split(',').forEach(part => {
const [key, value] = part.split('=');
parts[key] = value;
});
const timestamp = parseInt(parts.t, 10);
const receivedSignature = parts.v1;
if (!timestamp || !receivedSignature) {
throw new Error('Invalid signature format');
}
// Verifier le timestamp (protection replay attacks)
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - timestamp) > TIMESTAMP_TOLERANCE) {
throw new Error('Timestamp expired - possible replay attack');
}
// Calculer la signature attendue
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('hex');
// Comparaison timing-safe
const isValid = crypto.timingSafeEqual(
Buffer.from(receivedSignature, 'hex'),
Buffer.from(expectedSignature, 'hex')
);
if (!isValid) {
throw new Error('Invalid signature');
}
return { timestamp, verified: true };
}
/**
* Handler du webhook Scell.io
*/
app.post('/webhooks/scell', (req, res) => {
try {
// Verifier la signature
const signatureHeader = req.headers['x-scell-signature'];
const payload = req.body.toString('utf8');
verifyWebhookSignature(signatureHeader, payload);
// Parser le JSON
const event = JSON.parse(payload);
console.log(`Received event: ${event.event}`);
console.log(`Timestamp: ${event.timestamp}`);
console.log(`Data:`, event.data);
// Traiter l'evenement
switch (event.event) {
case 'invoice.validated':
handleInvoiceValidated(event.data);
break;
case 'invoice.rejected':
handleInvoiceRejected(event.data);
break;
case 'signature.completed':
handleSignatureCompleted(event.data);
break;
case 'signature.refused':
handleSignatureRefused(event.data);
break;
case 'balance.low':
handleBalanceLow(event.data);
break;
default:
console.log(`Unhandled event type: ${event.event}`);
}
res.status(200).send('OK');
} catch (error) {
console.error('Webhook error:', error.message);
if (error.message.includes('signature') || error.message.includes('Timestamp')) {
res.status(403).send(error.message);
} else {
res.status(500).send('Processing error');
}
}
});
// Handlers d'evenements
function handleInvoiceValidated(data) {
const invoice = data.invoice;
console.log(`Invoice validated: ${invoice.id} - ${invoice.invoice_number}`);
// Votre logique metier
}
function handleInvoiceRejected(data) {
console.log('Invoice rejected:', data);
// Votre logique metier
}
function handleSignatureCompleted(data) {
console.log('Signature completed:', data);
// Votre logique metier
}
function handleSignatureRefused(data) {
console.log('Signature refused:', data);
// Votre logique metier
}
function handleBalanceLow(data) {
console.log('Balance low warning:', data);
// Envoyer une alerte
}
app.listen(3000, () => {
console.log('Webhook server listening on port 3000');
});Variables d'environnement (.env) :
SCELL_WEBHOOK_SECRET=whsec_votre_secret_iciPython (Flask)
import hmac
import hashlib
import time
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "whsec_votre_secret_ici" # Utiliser os.environ en production
TIMESTAMP_TOLERANCE = 300 # 5 minutes
def verify_webhook_signature(signature_header: str, payload: bytes) -> dict:
"""
Verifie la signature du webhook Scell.io.
Args:
signature_header: Contenu du header X-Scell-Signature
payload: Corps de la requete en bytes
Returns:
dict avec timestamp et status de verification
Raises:
ValueError: Si la signature est invalide
"""
if not signature_header:
raise ValueError("Missing signature header")
# Parser le header: t=timestamp,v1=signature
parts = {}
for part in signature_header.split(','):
key, _, value = part.partition('=')
parts[key] = value
try:
timestamp = int(parts.get('t', '0'))
received_signature = parts.get('v1', '')
except (ValueError, TypeError):
raise ValueError("Invalid signature format")
if not timestamp or not received_signature:
raise ValueError("Invalid signature format")
# Verifier le timestamp (protection replay attacks)
current_time = int(time.time())
if abs(current_time - timestamp) > TIMESTAMP_TOLERANCE:
raise ValueError("Timestamp expired - possible replay attack")
# Calculer la signature attendue
signed_payload = f"{timestamp}.{payload.decode('utf-8')}"
expected_signature = hmac.new(
key=WEBHOOK_SECRET.encode('utf-8'),
msg=signed_payload.encode('utf-8'),
digestmod=hashlib.sha256
).hexdigest()
# Comparaison timing-safe
if not hmac.compare_digest(received_signature, expected_signature):
raise ValueError("Invalid signature")
return {"timestamp": timestamp, "verified": True}
@app.route('/webhooks/scell', methods=['POST'])
def handle_webhook():
"""
Point d'entree du webhook Scell.io.
"""
try:
# Verifier la signature
signature_header = request.headers.get('X-Scell-Signature')
payload = request.get_data()
verify_webhook_signature(signature_header, payload)
# Parser le JSON
event = request.get_json()
event_type = event.get('event')
event_data = event.get('data', {})
print(f"Received event: {event_type}")
print(f"Timestamp: {event.get('timestamp')}")
print(f"Data: {event_data}")
# Router vers le bon handler
handlers = {
'invoice.validated': handle_invoice_validated,
'invoice.rejected': handle_invoice_rejected,
'signature.completed': handle_signature_completed,
'signature.refused': handle_signature_refused,
'balance.low': handle_balance_low,
}
handler = handlers.get(event_type)
if handler:
handler(event_data)
else:
print(f"Unhandled event type: {event_type}")
return 'OK', 200
except ValueError as e:
print(f"Webhook verification failed: {e}")
return str(e), 403
except Exception as e:
print(f"Webhook processing error: {e}")
return 'Processing error', 500
def handle_invoice_validated(data: dict):
"""Traite l'evenement invoice.validated"""
invoice = data.get('invoice', {})
print(f"Invoice validated: {invoice.get('id')} - {invoice.get('invoice_number')}")
# Votre logique metier
def handle_invoice_rejected(data: dict):
"""Traite l'evenement invoice.rejected"""
print(f"Invoice rejected: {data}")
# Votre logique metier
def handle_signature_completed(data: dict):
"""Traite l'evenement signature.completed"""
print(f"Signature completed: {data}")
# Votre logique metier
def handle_signature_refused(data: dict):
"""Traite l'evenement signature.refused"""
print(f"Signature refused: {data}")
# Votre logique metier
def handle_balance_low(data: dict):
"""Traite l'evenement balance.low"""
print(f"Balance low warning: {data}")
# Envoyer une alerte
if __name__ == '__main__':
app.run(port=3000, debug=True)Avec FastAPI :
import hmac
import hashlib
import time
from fastapi import FastAPI, Request, HTTPException
from pydantic import BaseModel
from typing import Any, Dict
app = FastAPI()
WEBHOOK_SECRET = "whsec_votre_secret_ici"
TIMESTAMP_TOLERANCE = 300
class WebhookPayload(BaseModel):
event: str
webhook_id: str
timestamp: str
company_id: str | None = None
data: Dict[str, Any]
def verify_signature(signature_header: str, payload: bytes) -> bool:
"""Verifie la signature du webhook."""
if not signature_header:
return False
parts = dict(p.split('=', 1) for p in signature_header.split(',') if '=' in p)
timestamp = int(parts.get('t', 0))
received_sig = parts.get('v1', '')
# Check timestamp
if abs(time.time() - timestamp) > TIMESTAMP_TOLERANCE:
return False
# Compute expected signature
signed_payload = f"{timestamp}.{payload.decode()}"
expected_sig = hmac.new(
WEBHOOK_SECRET.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(received_sig, expected_sig)
@app.post("/webhooks/scell")
async def webhook_handler(request: Request):
signature = request.headers.get("X-Scell-Signature")
body = await request.body()
if not verify_signature(signature, body):
raise HTTPException(status_code=403, detail="Invalid signature")
payload = WebhookPayload.parse_raw(body)
# Process event
match payload.event:
case "invoice.validated":
print(f"Invoice validated: {payload.data}")
case "signature.completed":
print(f"Signature completed: {payload.data}")
case _:
print(f"Received {payload.event}")
return {"status": "ok"}Bonnes pratiques
1. Repondre rapidement (< 5 secondes)
Scell.io attend une reponse HTTP 2xx dans les 30 secondes. Si votre traitement est long, accusez reception immediatement puis traitez en asynchrone :
// PHP - Traitement asynchrone avec Laravel
public function handle(Request $request): Response
{
// Verifier la signature...
// Mettre en queue pour traitement asynchrone
ProcessWebhookJob::dispatch($request->all());
// Repondre immediatement
return response('OK', 200);
}// Node.js - Traitement asynchrone
app.post('/webhooks/scell', async (req, res) => {
// Verifier la signature...
// Repondre immediatement
res.status(200).send('OK');
// Traiter en arriere-plan
setImmediate(() => {
processWebhook(JSON.parse(req.body.toString()));
});
});2. Idempotence
Les webhooks peuvent etre renvoyes en cas d'erreur. Utilisez le webhook_id et timestamp pour detecter les doublons :
// Verifier si deja traite
$cacheKey = "webhook:{$request->input('webhook_id')}:{$request->input('timestamp')}";
if (Cache::has($cacheKey)) {
return response('Already processed', 200);
}
// Traiter puis marquer comme fait
Cache::put($cacheKey, true, now()->addHours(24));3. Gerer les retries
Scell.io reessaie automatiquement avec un backoff exponentiel :
- 1ere tentative : immediate
- 2eme tentative : +1 minute
- 3eme tentative : +5 minutes
- 4eme tentative : +15 minutes
- 5eme tentative : +1 heure
Apres 10 echecs consecutifs, le webhook est automatiquement desactive.
4. Securiser votre endpoint
- Utilisez HTTPS obligatoirement
- Verifiez TOUJOURS la signature
- Restreignez les IPs si possible (IPs Scell.io disponibles sur demande)
- Loggez les tentatives echouees
5. Tester en sandbox
Utilisez l'environnement sandbox pour tester sans impact sur la production :
curl -X POST https://api.scell.io/api/v1/webhooks/{id}/test \
-H "Authorization: Bearer {token}"Gestion des erreurs
Codes de reponse attendus
| Code | Signification | Action Scell.io |
|---|---|---|
| 2xx | Succes | Marque comme delivre |
| 4xx (sauf 429) | Erreur client | Ne reessaie PAS |
| 429 | Rate limit | Reessaie avec backoff |
| 5xx | Erreur serveur | Reessaie avec backoff |
| Timeout | Pas de reponse | Reessaie avec backoff |
Messages d'erreur recommandes
// Signature invalide
return response()->json([
'error' => 'webhook_signature_invalid',
'message' => 'La signature du webhook ne correspond pas.'
], 403);
// Timestamp expire
return response()->json([
'error' => 'webhook_timestamp_expired',
'message' => 'Le timestamp du webhook est trop ancien.'
], 403);
// Evenement non supporte
return response()->json([
'error' => 'event_not_supported',
'message' => 'Type d\'evenement non gere.'
], 200); // 200 car ce n'est pas une erreur
// Erreur de traitement
return response()->json([
'error' => 'processing_error',
'message' => 'Erreur lors du traitement. Reessayez.'
], 500);Debugger les webhooks
Via l'API
Consultez les logs de livraison :
curl https://api.scell.io/api/v1/webhooks/{id}/logs \
-H "Authorization: Bearer {token}"Tester localement
Utilisez un tunnel comme ngrok pour exposer votre serveur local :
# Installer ngrok
npm install -g ngrok
# Exposer le port 3000
ngrok http 3000
# Utiliser l'URL ngrok comme URL de webhook
# https://abc123.ngrok.io/webhooks/scellRejouer un webhook
Contactez le support pour rejouer un webhook specifique si necessaire.
Voir aussi
- Synchronisation avec les PDP - Mecanisme de synchronisation avec SuperPDP et autres plateformes
Support
- Documentation API : https://api.scell.io/api/documentation
- Email : support@scell.io
- Dashboard : https://app.scell.io