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

> Receive Lekalao's events, and check that they really come from it.

Lekalao calls your application when something happens: a subscription, an unsubscribe, a bounce. You create the address to call in **Settings → Outgoing webhooks** ([how to](/account/integrations#outgoing-webhooks)).

## The request

One JSON `POST` per event and per endpoint:

```http theme={null}
POST /webhooks/lekalao HTTP/1.1
Host: myshop.example
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-…"}}
```

Answer with a `2xx` code within **15 seconds**. Redirects are not followed.

## The events

### Subscribers

`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` is there only for the two tag events.

| Event                                            | When                                                                                                                                                                    |
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `subscriber.created`                             | The person **becomes a subscriber**: right away on a list without double opt-in, at the confirmation click otherwise. Also when someone comes back after unsubscribing. |
| `subscriber.confirmed`                           | They click the double opt-in link. Sent just before `subscriber.created`.                                                                                               |
| `subscriber.unsubscribed`                        | They unsubscribe, through the link, their preferences page, their mail client, the API, the team, or because of a hard bounce or a complaint.                           |
| `subscriber.tag_added`, `subscriber.tag_removed` | A tag is added or removed, by anyone.                                                                                                                                   |

<Note>
  Someone who subscribes to a double opt-in list and never confirms sets off
  no event at all.
</Note>

### Campaigns

`campaign.sent`, once a campaign's last e-mail has gone out:

```json theme={null}
{
    "event": "campaign.sent",
    "data": {
        "id": "0c9e3f5a-…",
        "name": "September letter",
        "subject": "The breads of the new season",
        "list": "9d5c2a1e-…",
        "sent": 1284,
        "sent_at": "2026-09-17T08:00:12+00:00"
    }
}
```

### Provider feedback

`mail.bounced` and `mail.complaint`, when a provider reports a bounce or a complaint:

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

`type` is `hard_bounce`, `soft_bounce` or `complaint`. `campaign` is `null` for a transactional or automation e-mail.

### Filtering by list

An endpoint set to **Only for one list** hears only about that list. `campaign.sent` follows the campaign's list.

## Check the signature

`X-Lekalao-Signature` is the HMAC SHA-256, in hexadecimal, of the **raw body** of the request, with the endpoint's **signing secret**. Compute it on the bytes you received, before any JSON decoding, and compare in constant time.

<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>
  Do not re-encode the JSON before computing the signature: one space or a
  different key order changes the result.
</Warning>

## Failures

* Lekalao **does not retry on its own**. Every call is in the **Calls we made** log, with your server's answer, and **Send again** replays it.
* After **10 failures in a row**, the endpoint is switched off. Switch it back on once your server is fixed.

## Advice

* **Answer quickly.** Queue the work and answer `204` at once.
* **Be idempotent.** The same event can arrive twice, after a **Send again** for example: keep track of what you have already handled (event, id or address, date) to ignore a duplicate.
* **Do not count on the order.** Two events close together can arrive the wrong way round; read the resource back through the API when the state matters.
* **Locally**, expose your server through a tunnel (ngrok, Cloudflare Tunnel): Lekalao refuses to call a private address or `localhost`.
