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

# Webhooks

> Get a signed HTTP call each time an email changes status.

Instead of polling, let PulseSend call your server when something happens to an email: delivered, bounced, marked as spam.

## Add an endpoint

In the dashboard, open **Webhooks** and click **Add endpoint**. Enter a public HTTPS URL and pick the events to receive, or all of them. Each endpoint gets its own signing secret, shown on its page, where you can also rotate it.

Use **Send test** to check your endpoint: PulseSend sends a `webhook.test` event, whatever the endpoint subscribes to. Every attempt shows up under **Deliveries** with the response code, and any delivery can be resent with the same event `id`.

Endpoints must use `https://` and resolve to a public address: `localhost`, private networks and cloud metadata addresses are refused.

## Events

| Event | Sent when |
| - | - |
| `email.sent` | The email was handed to the mail server. |
| `email.delivered` | The recipient's server accepted it. |
| `email.delivery_delayed` | The recipient's server is deferring. |
| `email.bounced` | The email was rejected. |
| `email.complained` | The recipient marked it as spam. |
| `email.failed` | The email couldn't be sent. Credits were refunded. |
| `email.canceled` | The email was canceled before sending. |
| `webhook.test` | You clicked **Send test** in the dashboard. |

## Payload

Each call is a `POST` with a JSON body:

```json theme={"dark"}
{
    "id": "evt_01jbz9a7k3m4n5p6q7r8s9t0uv",
    "type": "email.delivered",
    "created_at": "2026-10-17T09:41:07Z",
    "data": {
        "email_id": "em_01jbz8m3k2q7x9c4v5n6p8r0st",
        "status": "delivered"
    }
}
```

`email.failed` adds a `reason` to `data`. Use `id` to drop duplicates: the same event can arrive more than once.

## Verify the signature

Every call carries an `X-PulseSend-Signature` header:

```
X-PulseSend-Signature: t=1792230067,v1=5f2b8c...
```

`v1` is an HMAC-SHA256 of `{t}.{raw body}` with your endpoint's secret. Check it before trusting the payload, and reject timestamps older than five minutes to block replays.

<Warning>
  Sign the **raw** request body, byte for byte. Parsing and re-serializing the
  JSON changes it and the signature won't match.
</Warning>

<CodeGroup>
  ```js Node.js (Express) theme={"dark"}
  import crypto from 'node:crypto';
  import express from 'express';

  const app = express();

  app.post(
      '/webhooks/pulsesend',
      express.raw({ type: 'application/json' }),
      (req, res) => {
          const header = req.get('X-PulseSend-Signature') ?? '';
          const parts = Object.fromEntries(
              header.split(',').map((p) => p.trim().split('=')),
          );
          const t = Number(parts.t);

          if (!t || Math.abs(Date.now() / 1000 - t) > 300)
              return res.sendStatus(400);

          const expected = crypto
              .createHmac('sha256', process.env.PULSESEND_WEBHOOK_SECRET)
              .update(`${t}.${req.body}`)
              .digest('hex');

          const valid =
              parts.v1?.length === expected.length &&
              crypto.timingSafeEqual(
                  Buffer.from(parts.v1),
                  Buffer.from(expected),
              );

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

          const event = JSON.parse(req.body);
          // handle event.type ...
          res.sendStatus(200);
      },
  );
  ```

  ```php Laravel theme={"dark"}
  use Illuminate\Http\Request;

  Route::post('/webhooks/pulsesend', function (Request $request) {
      $payload = $request->getContent();
      parse_str(str_replace(',', '&', (string) $request->header('X-PulseSend-Signature')), $parts);

      $t = (int) ($parts['t'] ?? 0);
      $expected = hash_hmac('sha256', $t.'.'.$payload, config('services.pulsesend.webhook_secret'));

      abort_if(abs(time() - $t) > 300, 400);
      abort_unless(hash_equals($expected, (string) ($parts['v1'] ?? '')), 400);

      $event = json_decode($payload, true);
      // handle $event['type'] ...

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

  ```python Python (Flask) theme={"dark"}
  import hashlib, hmac, os, time
  from flask import Flask, abort, request

  app = Flask(__name__)

  @app.post("/webhooks/pulsesend")
  def pulsesend_webhook():
      payload = request.get_data()
      parts = dict(p.strip().split("=", 1) for p in request.headers.get("X-PulseSend-Signature", "").split(",") if "=" in p)
      t = int(parts.get("t", 0))

      expected = hmac.new(os.environ["PULSESEND_WEBHOOK_SECRET"].encode(), f"{t}.".encode() + payload, hashlib.sha256).hexdigest()

      if abs(time.time() - t) > 300 or not hmac.compare_digest(expected, parts.get("v1", "")):
          abort(400)

      event = request.get_json()
      # handle event["type"] ...
      return "", 204
  ```
</CodeGroup>

In Laravel, exclude the route from CSRF protection.

## Responding and retries

Answer with any `2xx` status within 10 seconds. Do the heavy work in a queue after answering.

A timeout, a network error or any other status counts as a failure. PulseSend then retries 6 times over about a day: after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 15 hours. Redirects aren't followed.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.