Skip to content

Webhooks Scell.io

Cette documentation decrit comment recevoir et verifier les notifications webhook de Scell.io.

Table des matieres

  1. Vue d'ensemble
  2. Evenements disponibles
  3. Format du payload
  4. Headers HTTP
  5. Verification de la signature
  6. Exemples de code
  7. Bonnes pratiques
  8. 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 :

  1. Vous configurez une URL de webhook dans votre dashboard ou via l'API
  2. Scell.io vous fournit un secret unique (format : whsec_xxxx...)
  3. A chaque evenement, Scell.io envoie une requete POST signee a votre URL
  4. Votre serveur verifie la signature et traite l'evenement

Evenements disponibles

Facturation electronique (sortante)

EvenementDescription
invoice.createdUne facture a ete creee
invoice.validatedLa facture a ete validee (Factur-X/UBL genere)
invoice.transmittedLa facture a ete transmise au PDP
invoice.acceptedLa facture a ete acceptee par le destinataire
invoice.rejectedLa facture a ete refusee
invoice.errorErreur lors du traitement de la facture

Facturation electronique (entrante)

EvenementDescription
invoice.incoming.receivedNouvelle facture recue d'un fournisseur
invoice.incoming.validatedLa facture entrante a ete validee techniquement
invoice.incoming.acceptedLa facture entrante a ete acceptee
invoice.incoming.rejectedLa facture entrante a ete rejetee
invoice.incoming.disputedLa facture entrante a ete contestee
invoice.incoming.paidLa facture entrante a ete marquee comme payee

Signature electronique

EvenementDescription
signature.createdUne demande de signature a ete creee
signature.waitingEn attente de signature
signature.signedUn signataire a signe
signature.completedTous les signataires ont signe
signature.refusedLa signature a ete refusee
signature.expiredLa demande de signature a expire
signature.errorErreur lors du processus de signature

Compte

EvenementDescription
balance.lowSolde de credits faible (seuil d'alerte)
balance.criticalSolde de credits critique

Onboarding B2B (Tenant partenaire)

EvenementDescription
onboarding.startedUne session d'onboarding a ete demarree
onboarding.step_completedUne etape d'onboarding a ete completee
onboarding.completedL'onboarding a ete finalise avec succes
onboarding.failedL'onboarding a echoue ou a ete abandonne

Credit Notes (Avoirs)

EvenementDescription
credit_note.createdUn avoir a ete cree
credit_note.sentUn avoir a ete valide et envoye
credit_note.cancelledUn avoir a ete annule

Format du payload

Chaque webhook est envoye en POST avec un corps JSON structure ainsi :

json
{
  "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

json
{
  "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

json
{
  "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

json
{
  "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

json
{
  "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

json
{
  "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

json
{
  "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

json
{
  "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

json
{
  "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

json
{
  "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

json
{
  "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

json
{
  "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

ChampTypeDescription
eventstringType d'evenement (ex: invoice.validated)
webhook_iduuidIdentifiant unique du webhook configure
timestampISO 8601Date/heure de l'evenement
company_iduuidIdentifiant de l'entreprise concernee
dataobjectDonnees specifiques a l'evenement

Headers HTTP

Chaque requete webhook inclut les headers suivants :

HeaderDescriptionExemple
Content-TypeType de contenuapplication/json
X-Scell-SignatureSignature HMACt=1706090400,v1=abc123...
X-Scell-EventType d'evenementinvoice.validated
X-Scell-DeliveryID unique de livraisond4e5f6...
User-AgentAgent utilisateurScell.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

  1. Extraire le timestamp et la signature du header X-Scell-Signature

    • Format : t={timestamp},v1={signature}
  2. Construire le payload a signer

    • Concatener : {timestamp}.{body_json}
  3. Calculer le HMAC-SHA256

    • Cle : votre secret webhook (whsec_...)
    • Message : le payload construit a l'etape 2
  4. Comparer les signatures de maniere securisee (timing-safe)

  5. 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
<?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) :

php
Route::post('/webhooks/scell', [WebhookController::class, 'handle'])
    ->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);

Configuration (.env) :

env
SCELL_WEBHOOK_SECRET=whsec_votre_secret_ici

Note : 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 un SCELL_WEBHOOK_SECRET distinct.

Configuration (config/services.php) :

php
'scell' => [
    'webhook_secret' => env('SCELL_WEBHOOK_SECRET'),
],

JavaScript / Node.js (Express)

javascript
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) :

env
SCELL_WEBHOOK_SECRET=whsec_votre_secret_ici

Python (Flask)

python
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 :

python
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
// 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);
}
javascript
// 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 :

php
// 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 :

bash
curl -X POST https://api.scell.io/api/v1/webhooks/{id}/test \
  -H "Authorization: Bearer {token}"

Gestion des erreurs

Codes de reponse attendus

CodeSignificationAction Scell.io
2xxSuccesMarque comme delivre
4xx (sauf 429)Erreur clientNe reessaie PAS
429Rate limitReessaie avec backoff
5xxErreur serveurReessaie avec backoff
TimeoutPas de reponseReessaie avec backoff

Messages d'erreur recommandes

php
// 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 :

bash
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 :

bash
# 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/scell

Rejouer un webhook

Contactez le support pour rejouer un webhook specifique si necessaire.


Voir aussi


Support

Documentation Scell.io