# 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'),
);
```

## 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.


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


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


```json
{
    "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`).


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


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


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