> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lekalao.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Recevoir les événements de Lekalao et vérifier qu'ils viennent bien de lui.

Lekalao appelle votre application quand quelque chose arrive : une inscription, un désabonnement, un rebond. Vous créez l'adresse à appeler dans **Paramètres → Webhooks sortants** ([mode d'emploi](/fr/account/integrations#webhooks-sortants)).

## La requête

Un `POST` JSON par événement et par adresse :

```http theme={null}
POST /webhooks/lekalao HTTP/1.1
Host: maboutique.fr
Content-Type: application/json
X-Lekalao-Event: subscriber.unsubscribed
X-Lekalao-Signature: 5d41b3c0e8a7f0b2c7e1d4a9f8b6c3e2a1d0f9e8b7c6a5d4e3f2a1b0c9d8e7f6

{"event":"subscriber.unsubscribed","data":{"id":"5f0e7c1b-…","email":"ada@example.com","first_name":"Ada","last_name":"Lovelace","status":"unsubscribed","list":"9d5c2a1e-…"}}
```

Répondez un code `2xx` dans les **15 secondes**. Les redirections ne sont pas suivies.

## Les événements

### Abonnés

`subscriber.created`, `subscriber.confirmed`, `subscriber.unsubscribed`, `subscriber.tag_added`, `subscriber.tag_removed`.

```json theme={null}
{
    "event": "subscriber.tag_added",
    "data": {
        "id": "5f0e7c1b-2d4a-4e8f-b1c3-9a7d6e5f4b3a",
        "email": "ada@example.com",
        "first_name": "Ada",
        "last_name": "Lovelace",
        "status": "subscribed",
        "list": "9d5c2a1e-8f3b-4c7a-9e21-3b8f0c6d4a12",
        "tag": "vip"
    }
}
```

`tag` n'est présent que pour les deux événements de tags.

| Événement                                        | Quand                                                                                                                                                                  |
| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `subscriber.created`                             | La personne **devient abonnée** : tout de suite sur une liste sans double opt-in, au clic de confirmation sinon. Aussi quand quelqu'un revient après s'être désabonné. |
| `subscriber.confirmed`                           | Elle clique le lien de double opt-in. Envoyé juste avant `subscriber.created`.                                                                                         |
| `subscriber.unsubscribed`                        | Elle se désabonne, par le lien, sa page de préférences, son client mail, l'API, l'équipe, ou à cause d'un rebond définitif ou d'une plainte.                           |
| `subscriber.tag_added`, `subscriber.tag_removed` | Un tag est posé ou retiré, par qui que ce soit.                                                                                                                        |

<Note>
  Une personne inscrite sur une liste en double opt-in qui ne confirme jamais
  ne déclenche aucun événement.
</Note>

### Campagnes

`campaign.sent`, quand le dernier e-mail d'une campagne est parti :

```json theme={null}
{
    "event": "campaign.sent",
    "data": {
        "id": "0c9e3f5a-…",
        "name": "Lettre de septembre",
        "subject": "Les pains de la rentrée",
        "list": "9d5c2a1e-…",
        "sent": 1284,
        "sent_at": "2026-09-17T08:00:12+00:00"
    }
}
```

### Retours des fournisseurs

`mail.bounced` et `mail.complaint`, quand un fournisseur signale un rebond ou une plainte :

```json theme={null}
{
    "event": "mail.bounced",
    "data": {
        "email": "ancien@example.com",
        "type": "hard_bounce",
        "reason": "550 5.1.1 The email account that you tried to reach does not exist.",
        "campaign": "0c9e3f5a-…"
    }
}
```

`type` vaut `hard_bounce`, `soft_bounce` ou `complaint`. `campaign` est `null` pour un e-mail transactionnel ou d'automatisation.

### Filtrer par liste

Une adresse réglée sur **Seulement pour une liste** ne reçoit que les événements de cette liste. `campaign.sent` suit la liste de la campagne.

## Vérifier la signature

`X-Lekalao-Signature` est le HMAC SHA-256, en hexadécimal, du **corps brut** de la requête, avec le **secret de signature** de l'adresse. Calculez-le sur les octets reçus, avant tout décodage JSON, et comparez en temps constant.

<CodeGroup>
  ```php Laravel theme={null}
  use Illuminate\Http\Request;

  Route::post('/webhooks/lekalao', function (Request $request) {
      $expected = hash_hmac('sha256', $request->getContent(), config('services.lekalao.webhook_secret'));

      abort_unless(hash_equals($expected, (string) $request->header('X-Lekalao-Signature')), 401);

      match ($request->input('event')) {
          'subscriber.unsubscribed' => Customer::where('email', $request->input('data.email'))
              ->update(['newsletter' => false]),
          default => null,
      };

      return response()->noContent();
  });
  ```

  ```js Node (Express) theme={null}
  import crypto from 'node:crypto';
  import express from 'express';

  const app = express();

  app.post(
      '/webhooks/lekalao',
      express.raw({ type: 'application/json' }),
      (req, res) => {
          const expected = crypto
              .createHmac('sha256', process.env.LEKALAO_WEBHOOK_SECRET)
              .update(req.body)
              .digest('hex');
          const received = req.get('X-Lekalao-Signature') ?? '';

          const valid =
              received.length === expected.length &&
              crypto.timingSafeEqual(
                  Buffer.from(received),
                  Buffer.from(expected),
              );

          if (!valid) return res.sendStatus(401);

          const { event, data } = JSON.parse(req.body);
          // …
          res.sendStatus(204);
      },
  );
  ```

  ```python Python (Flask) theme={null}
  import hashlib
  import hmac
  import os

  from flask import Flask, abort, request

  app = Flask(__name__)

  @app.post("/webhooks/lekalao")
  def lekalao():
      expected = hmac.new(
          os.environ["LEKALAO_WEBHOOK_SECRET"].encode(),
          request.get_data(),
          hashlib.sha256,
      ).hexdigest()

      if not hmac.compare_digest(expected, request.headers.get("X-Lekalao-Signature", "")):
          abort(401)

      payload = request.get_json()
      # …
      return "", 204
  ```
</CodeGroup>

<Warning>
  Ne ré-encodez pas le JSON avant de calculer la signature : un espace ou un
  ordre de clés différent change le résultat.
</Warning>

## Échecs

* Lekalao **ne réessaie pas tout seul**. Chaque appel est dans le journal **Appels que nous avons faits**, avec la réponse de votre serveur, et **Renvoyer** le rejoue.
* Après **10 échecs d'affilée**, l'adresse est désactivée. Réactivez-la une fois votre serveur réparé.

## Conseils

* **Répondez vite.** Mettez le travail en file et répondez `204` tout de suite.
* **Soyez idempotent.** Le même événement peut arriver deux fois, par exemple après un **Renvoyer** : gardez la trace de ce que vous avez déjà traité (événement, id ou adresse, date) pour ignorer un doublon.
* **Ne supposez pas l'ordre.** Deux événements proches peuvent arriver dans le désordre ; relisez la ressource par l'API si l'état compte.
* **En local**, exposez votre serveur avec un tunnel (ngrok, Cloudflare Tunnel) : Lekalao refuse d'appeler une adresse privée ou `localhost`.
