Aller au contenu
NessFlow
Menu
    Sommaire de la documentation

    Événements de webhook

    Chaque événement part en POST, signé, vers les adresses que vous avez abonnées. Répondez par un code 2xx en moins de 10 secondes ; tout autre résultat est relancé.

    Vérifier la signature

    L’en-tête NessFlow-Signature porte un horodatage et une ou plusieurs signatures v1, chacune étant le HMAC-SHA256 de « horodatage.corps brut » avec le secret du webhook. Acceptez l’envoi si une signature correspond et si l’horodatage date de moins de 300 secondes.

    PHP

    function nessflowSignatureIsValid(string $header, string $body, string $secret): bool
    {
        $timestamp = null;
        $signatures = [];
    
        foreach (explode(',', $header) as $part) {
            [$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
    
            if ($key === 't' && ctype_digit($value)) {
                $timestamp = (int) $value;
            } elseif ($key === 'v1') {
                $signatures[] = $value;
            }
        }
    
        if ($timestamp === null || abs(time() - $timestamp) > 300) {
            return false;
        }
    
        $expected = hash_hmac('sha256', $timestamp.'.'.$body, $secret);
    
        foreach ($signatures as $signature) {
            if (hash_equals($expected, $signature)) {
                return true;
            }
        }
    
        return false;
    }
    
    // $body : le corps BRUT, avant tout décodage JSON.
    $valid = nessflowSignatureIsValid(
        $_SERVER['HTTP_NESSFLOW_SIGNATURE'] ?? '',
        file_get_contents('php://input'),
        getenv('NESSFLOW_WEBHOOK_SECRET'),
    );

    JavaScript

    import crypto from 'node:crypto';
    
    export function nessflowSignatureIsValid(header, rawBody, secret) {
      let timestamp = null;
      const signatures = [];
    
      for (const part of header.split(',')) {
        const [key, value = ''] = part.trim().split('=', 2);
        if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value);
        else if (key === 'v1') signatures.push(value);
      }
    
      if (timestamp === null || Math.abs(Date.now() / 1000 - timestamp) > 300) {
        return false;
      }
    
      const expected = crypto
        .createHmac('sha256', secret)
        .update(`${timestamp}.${rawBody}`)
        .digest('hex');
    
      return signatures.some(
        (signature) =>
          signature.length === expected.length &&
          crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)),
      );
    }

    Python

    import hashlib
    import hmac
    import time
    
    
    def nessflow_signature_is_valid(header: str, raw_body: bytes, secret: str) -> bool:
        timestamp = None
        signatures = []
    
        for part in header.split(","):
            key, _, value = part.strip().partition("=")
            if key == "t" and value.isdigit():
                timestamp = int(value)
            elif key == "v1":
                signatures.append(value)
    
        if timestamp is None or abs(time.time() - timestamp) > 300:
            return False
    
        expected = hmac.new(
            secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
        ).hexdigest()
    
        return any(hmac.compare_digest(expected, s) for s in signatures)

    Relances et désactivation

    Un envoi refusé est relancé 8 fois au total sur 24 heures. Après 5 livraisons épuisées consécutives, l’abonnement est désactivé et les administrateurs de l’équipe sont prévenus par e-mail.

    Un même événement peut arriver deux fois : dédupliquez sur l’en-tête NessFlow-Event-Id, identique à chaque tentative.

    Catalogue des événements

    crawl.completed

    Un crawl est terminé

    Envoyé quand un crawl atteint completed. Le post-traitement peut encore tourner : data.stage_plan.settled dit si tous les rapports sont calculés.

    Exemple de corps

    {
        "id": "0190f5a2-7c4e-7b1a-9d3e-2f6b8c1a4e57",
        "type": "crawl.completed",
        "api_version": "v1",
        "created_at": "2026-10-01T09:30:00Z",
        "data": {
            "id": "0190f5a2-7c4e-7b1a-9d3e-2f6b8c1a4e57",
            "project_id": "0190f5a2-7c4e-7b1a-9d3e-2f6b8c1a4e57",
            "campaign_id": "0190f5a2-7c4e-7b1a-9d3e-2f6b8c1a4e57",
            "status": "queued",
            "stage": "string",
            "active": true,
            "stage_plan": {
                "current_step": 42,
                "step_count": 42,
                "settled": true,
                "steps": [
                    {
                        "stage": "string",
                        "state": "pending",
                        "skip_reason": "string"
                    }
                ]
            },
            "pages": {
                "crawled": 42,
                "discovered": 42,
                "budget": 42
            },
            "issue_counts": {
                "error": 42,
                "warning": 42,
                "info": 42
            },
            "truncated": true,
            "blocked": true,
            "blocked_reason": "string",
            "comparable": true,
            "error": "string",
            "trigger": "manual",
            "started_at": "2026-10-01T09:30:00Z",
            "finished_at": "2026-10-01T09:30:00Z",
            "created_at": "2026-10-01T09:30:00Z"
        }
    }

    crawl.failed

    Un crawl a échoué

    Envoyé quand un crawl atteint failed ; data.error en donne le motif stable.

    Exemple de corps

    {
        "id": "0190f5a2-7c4e-7b1a-9d3e-2f6b8c1a4e57",
        "type": "crawl.failed",
        "api_version": "v1",
        "created_at": "2026-10-01T09:30:00Z",
        "data": {
            "id": "0190f5a2-7c4e-7b1a-9d3e-2f6b8c1a4e57",
            "project_id": "0190f5a2-7c4e-7b1a-9d3e-2f6b8c1a4e57",
            "campaign_id": "0190f5a2-7c4e-7b1a-9d3e-2f6b8c1a4e57",
            "status": "queued",
            "stage": "string",
            "active": true,
            "stage_plan": {
                "current_step": 42,
                "step_count": 42,
                "settled": true,
                "steps": [
                    {
                        "stage": "string",
                        "state": "pending",
                        "skip_reason": "string"
                    }
                ]
            },
            "pages": {
                "crawled": 42,
                "discovered": 42,
                "budget": 42
            },
            "issue_counts": {
                "error": 42,
                "warning": 42,
                "info": 42
            },
            "truncated": true,
            "blocked": true,
            "blocked_reason": "string",
            "comparable": true,
            "error": "string",
            "trigger": "manual",
            "started_at": "2026-10-01T09:30:00Z",
            "finished_at": "2026-10-01T09:30:00Z",
            "created_at": "2026-10-01T09:30:00Z"
        }
    }

    crawl.stopped

    Un crawl a été arrêté

    Envoyé quand un crawl est arrêté, par un utilisateur ou parce qu’il est resté sans exécutant.

    Exemple de corps

    {
        "id": "0190f5a2-7c4e-7b1a-9d3e-2f6b8c1a4e57",
        "type": "crawl.stopped",
        "api_version": "v1",
        "created_at": "2026-10-01T09:30:00Z",
        "data": {
            "id": "0190f5a2-7c4e-7b1a-9d3e-2f6b8c1a4e57",
            "project_id": "0190f5a2-7c4e-7b1a-9d3e-2f6b8c1a4e57",
            "campaign_id": "0190f5a2-7c4e-7b1a-9d3e-2f6b8c1a4e57",
            "status": "queued",
            "stage": "string",
            "active": true,
            "stage_plan": {
                "current_step": 42,
                "step_count": 42,
                "settled": true,
                "steps": [
                    {
                        "stage": "string",
                        "state": "pending",
                        "skip_reason": "string"
                    }
                ]
            },
            "pages": {
                "crawled": 42,
                "discovered": 42,
                "budget": 42
            },
            "issue_counts": {
                "error": 42,
                "warning": 42,
                "info": 42
            },
            "truncated": true,
            "blocked": true,
            "blocked_reason": "string",
            "comparable": true,
            "error": "string",
            "trigger": "manual",
            "started_at": "2026-10-01T09:30:00Z",
            "finished_at": "2026-10-01T09:30:00Z",
            "created_at": "2026-10-01T09:30:00Z"
        }
    }

    export.ready

    Un export est prêt

    Envoyé quand un export demandé peut être téléchargé (data.download_url).

    Exemple de corps

    {
        "id": "0190f5a2-7c4e-7b1a-9d3e-2f6b8c1a4e57",
        "type": "export.ready",
        "api_version": "v1",
        "created_at": "2026-10-01T09:30:00Z",
        "data": {
            "id": "0190f5a2-7c4e-7b1a-9d3e-2f6b8c1a4e57",
            "crawl_id": "0190f5a2-7c4e-7b1a-9d3e-2f6b8c1a4e57",
            "format": "pdf",
            "status": "queued",
            "size_bytes": 42,
            "download_url": "https://exemple.fr/",
            "created_at": "2026-10-01T09:30:00Z",
            "finished_at": "2026-10-01T09:30:00Z",
            "expires_at": "2026-10-01T09:30:00Z"
        }
    }

    recommendations.ready

    Les recommandations sont prêtes

    Envoyé quand une nouvelle série de recommandations a été générée. Lisez-les avec GET /v1/projects/{id}/recommendations.

    Exemple de corps

    {
        "id": "0190f5a2-7c4e-7b1a-9d3e-2f6b8c1a4e57",
        "type": "recommendations.ready",
        "api_version": "v1",
        "created_at": "2026-10-01T09:30:00Z",
        "data": {
            "project_id": "0190f5a2-7c4e-7b1a-9d3e-2f6b8c1a4e57",
            "crawl_id": "0190f5a2-7c4e-7b1a-9d3e-2f6b8c1a4e57",
            "generated_at": "2026-10-01T09:30:00Z"
        }
    }

    signal.detected

    Un signal est détecté

    Envoyé une fois par nouvel état d’un signal de surveillance (une régression après un crawl, une baisse de positions…), erreurs et avertissements confondus. Le même état ouvert n’est jamais envoyé deux fois.

    Exemple de corps

    {
        "id": "0190f5a2-7c4e-7b1a-9d3e-2f6b8c1a4e57",
        "type": "signal.detected",
        "api_version": "v1",
        "created_at": "2026-10-01T09:30:00Z",
        "data": {
            "project_id": "0190f5a2-7c4e-7b1a-9d3e-2f6b8c1a4e57",
            "source": "string",
            "severity": "warning",
            "events": [
                {
                    "code": "string",
                    "severity": "string",
                    "category": "string",
                    "summary": "string"
                }
            ]
        }
    }

    Référence de l’API (Webhooks) Codes d’erreur Documentation de l’API

    Cette page en Markdown