Skip to content
NessFlow
Menu
    Documentation contents

    Webhook events

    Every event is sent as a signed POST to the addresses you subscribed. Answer with a 2xx code within 10 seconds; any other outcome is retried.

    Verify the signature

    The NessFlow-Signature header carries a timestamp and one or more v1 signatures, each the HMAC-SHA256 of “timestamp.raw body” with the webhook secret. Accept the delivery if one signature matches and the timestamp is less than 300 seconds old.

    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)

    Retries and disablement

    A refused delivery is attempted 8 times in total over 24 hours. After 5 consecutive exhausted deliveries, the subscription is disabled and the team admins are told by e-mail.

    The same event may arrive twice: deduplicate on the NessFlow-Event-Id header, identical across attempts.

    Event catalogue

    crawl.completed

    A crawl finished

    Sent when a crawl reaches completed. Post-processing may still be running: data.stage_plan.settled says whether every report is computed.

    Example payload

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

    A crawl failed

    Sent when a crawl reaches failed; data.error gives the stable reason.

    Example payload

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

    A crawl was stopped

    Sent when a crawl is stopped, by a user or because it was left without a worker.

    Example payload

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

    An export is ready

    Sent when a requested export can be downloaded (data.download_url).

    Example payload

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

    Recommendations are ready

    Sent when a new set of recommendations was generated. Read them with GET /v1/projects/{id}/recommendations.

    Example payload

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

    A signal was detected

    Sent once per new state of a monitoring signal (a regression after a crawl, a drop in rankings…), errors and warnings alike. The same open state is never sent twice.

    Example payload

    {
        "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"
                }
            ]
        }
    }

    API reference (Webhooks) Error codes API documentation

    This page in Markdown