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

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

```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`

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

```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`

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.

```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`

Un export est prêt. Envoyé quand un export demandé peut être téléchargé (`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`

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

```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`

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.

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