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.submittedLa facture a ete soumise (envoyee au PDP / mise en transmission)
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.paidLa facture a ete marquee comme payee
invoice.sent_to_buyerLa facture a ete envoyee a l'acheteur par email
invoice.errorErreur lors du traitement de la facture

Facturation electronique (entrante) ​

EvenementDescription
invoice.incoming.createdUne facture entrante a ete creee dans le systeme
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.remindedUne relance a ete envoyee aux signataires en attente
signature.cancelledLa demande de signature a ete annulee
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

Remboursements ​

EvenementDescription
invoice.refund_status_changedLe statut de remboursement d'une facture a change (remboursement partiel/total)

Factures recurrentes ​

EvenementDescription
recurring_invoice.emittedUne echeance a emis une facture
recurring_invoice.completedLe profil recurrent a atteint son terme (end_mode)
recurring_invoice.failedL'emission d'une echeance a echoue
recurring_invoice.upcomingUne echeance approche (notification anticipee)

Ces notifications sont gatees par le flag tenant auto_notifications_enabled.

Fonctionnalites sans webhook ​

Certains domaines n'emettent pas de webhook :

  • Devis (Quotes) : la signature ou le refus d'un devis ne declenche pas de webhook. Le statut se consulte via GET /api/v1/quotes/{id} ou la page publique. La conversion d'un devis en facture acompte/solde, elle, declenche les evenements invoice.* normaux.
  • Echeanciers de paiement (Payment Schedule) : pas de webhook dedie ; les factures emises depuis un echeancier declenchent les evenements invoice.*.
  • Packs de credits (Credit Packs) : l'achat est traite cote serveur via le webhook Stripe (pas un webhook Scell.io sortant). Le credit de la balance peut etre suivi via balance.low / balance.critical et l'historique billing.

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_tax": 200.00,
      "total_ttc": 1200.00,
      "currency": "EUR",
      "issue_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