# Webhooks

> Every event Reggio Digital can send your application, an example payload for each, signature verification in PHP and Node, retries and deduplication.

Source: https://reggiodigital.com/developers/webhooks

Give us an https address in your application and we will post to it when something happens on the account, instead of your code asking over and over. Webhook addresses, the events each one hears about and signing secrets are managed on the [Notify my app](https://reggiodigital.com/email/webhooks) page in the dashboard, which also lists every delivery from the last 30 days.

## Setting up an endpoint

1. Add an address on [Notify my app](https://reggiodigital.com/email/webhooks). It has to start with `https` and be reachable from the public internet. Private and internal addresses are refused.
2. Pick the events it should hear about. An endpoint only ever receives the types it chose, so a new event type never starts arriving unannounced.
3. Copy its signing secret into your application.
4. Use **Send a test** to post a real, signed `email.delivered` event with `"test": true` in its data.

Events are recorded only while an endpoint is listening for them. Turning one on does not send history from before it existed.

## What we send

A `POST` with a JSON body in this envelope:

```json
{
  "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33",
  "version": "2026-09-01",
  "type": "email.bounced",
  "occurred_at": "2026-09-14T15:04:05+00:00",
  "replayed": false,
  "data": { }
}
```

- `id` is unique to the event and stays the same on every retry and replay.
- `version` is the shape of `data`. It only changes if a change would break an existing consumer.
- `replayed` is `true` when someone sent the event again by hand from the dashboard.

And these headers:

| Header | What it carries |
| --- | --- |
| X-Reggio-Webhook-Signature | Signed so your application can prove the message came from us. See below. |
| X-Reggio-Webhook-Id | The event id, the same as id in the body. It stays the same on every retry. |
| X-Reggio-Webhook-Type | The event type, the same as type in the body, so you can route before parsing. |
| Content-Type | Always application/json. |

## Answering

Answer with any `2xx` status within 10 seconds and we count the event as delivered. Do the real work after you answer, or on a queue, so a slow job does not look like a failure.

Anything else, including a timeout or a `3xx`, and we try again. Seven attempts in all, waiting 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours and 4 hours between them, about six and a half hours from first to last. After that the delivery is marked as failed, and it can be sent again from the dashboard once your endpoint is fixed.

Redirects are never followed. Give us the final address.

## Handling duplicates and order

- **At least once.** An event can arrive more than once, for example when your application was slow to answer the first time. Record the `id` of every event you handle and ignore one you have seen.
- **Not in order.** Events are delivered independently. A bounce for one message can arrive before the delivery of another, and an `email.delivered` can occasionally arrive after an `email.opened` for the same message. Use `occurred_at` and treat each event on its own.

## Verifying the signature

Every request carries `X-Reggio-Webhook-Signature`, which looks like:

```
X-Reggio-Webhook-Signature: t=1789398245,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

To check it:

1. Take the timestamp from `t` and the signature from `v1`.
2. Join the timestamp, a full stop and the raw request body, exactly as the bytes arrived, before any JSON parsing.
3. Compute HMAC SHA-256 of that string with your signing secret, as lowercase hex.
4. Compare it to `v1` with a constant-time comparison.
5. Reject the request if the timestamp is more than five minutes from your clock. The timestamp is part of what is signed, so it cannot be changed without breaking the signature.

PHP:

```php
function verifyReggioWebhook(string $rawBody, string $signatureHeader, string $secret, int $toleranceSeconds = 300): bool
{
    $parts = [];

    foreach (explode(',', $signatureHeader) as $piece) {
        [$name, $value] = array_pad(explode('=', trim($piece), 2), 2, '');
        $parts[$name] = $value;
    }

    $timestamp = (int) ($parts['t'] ?? 0);
    $signature = $parts['v1'] ?? '';

    if ($timestamp <= 0 || $signature === '' || abs(time() - $timestamp) > $toleranceSeconds) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);

    return hash_equals($expected, $signature);
}

// $rawBody = file_get_contents('php://input');
// $ok = verifyReggioWebhook($rawBody, $_SERVER['HTTP_X_REGGIO_WEBHOOK_SIGNATURE'] ?? '', getenv('REGGIO_WEBHOOK_SECRET'));
```

Node.js:

```js
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyReggioWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    String(signatureHeader ?? "")
      .split(",")
      .map((piece) => piece.trim().split("=", 2))
      .filter((pair) => pair.length === 2),
  );

  const timestamp = Number.parseInt(parts.t ?? "0", 10);
  const signature = parts.v1 ?? "";

  if (!timestamp || !signature || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
    return false;
  }

  const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");

  return expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

// Express: read the body as text before any JSON parsing, so the bytes match what we signed.
// app.post("/reggio", express.text({ type: "application/json" }), (req, res) => {
//   if (!verifyReggioWebhook(req.body, req.get("X-Reggio-Webhook-Signature"), process.env.REGGIO_WEBHOOK_SECRET)) {
//     return res.sendStatus(400);
//   }
//   const event = JSON.parse(req.body);
//   res.sendStatus(200);
// });
```

If the secret is right and the check still fails, make sure you are hashing the raw body. A framework that parses JSON before your handler runs gives you a re-encoded copy with different whitespace.

Replacing a secret in the dashboard takes effect on the next event, so update your application straight after.

## Event types

| Event type | Sent when |
| --- | --- |
| Email you send |
| email.delivered | An email arrived |
| email.bounced | An email bounced |
| email.complained | Someone marked an email as spam |
| email.delayed | An email is taking longer than usual |
| email.opened | Someone opened an email |
| email.clicked | Someone clicked a link in an email |
| email.failed | An email could not be sent |
| Your newsletter |
| newsletter.subscriber.subscribed | Someone joined a list |
| newsletter.subscriber.confirmed | Someone confirmed their subscription |
| newsletter.subscriber.unsubscribed | Someone left a list |
| newsletter.preferences.updated | Someone changed what they want to hear about |
| Mail you receive |
| inbox.message.received | Mail arrived in your inbox |

Every example below is the full body your endpoint receives.

### email.delivered

The receiving mail server accepted the message. `detail.detail` is its reply.

```json
{
    "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33",
    "version": "2026-09-01",
    "type": "email.delivered",
    "occurred_at": "2026-09-14T15:04:05+00:00",
    "replayed": false,
    "data": {
        "message_id": "0100019a1b2c3d4e-5f6a7b8c-1234-5678-9abc-def012345678-000000",
        "to": "customer@example.com",
        "from": "hello@yourdomain.com",
        "subject": "Your receipt",
        "status": "delivered",
        "tags": {
            "kind": "receipt",
            "order": "1042"
        },
        "detail": {
            "detail": "250 2.0.0 OK"
        }
    }
}
```

### email.bounced

The receiving server refused the message. `detail.bounce_type` is `Permanent` for an address that will never work and `Transient` for one worth trying later; `detail.bounce_subtype` says why.

```json
{
    "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33",
    "version": "2026-09-01",
    "type": "email.bounced",
    "occurred_at": "2026-09-14T15:04:05+00:00",
    "replayed": false,
    "data": {
        "message_id": "0100019a1b2c3d4e-5f6a7b8c-1234-5678-9abc-def012345678-000000",
        "to": "customer@example.com",
        "from": "hello@yourdomain.com",
        "subject": "Your receipt",
        "status": "bounced",
        "tags": {
            "kind": "receipt",
            "order": "1042"
        },
        "detail": {
            "detail": "smtp; 550 5.1.1 user unknown",
            "bounce_type": "Permanent",
            "bounce_subtype": "General"
        }
    }
}
```

### email.complained

The recipient marked the message as spam. Stop sending them anything that is not strictly necessary.

```json
{
    "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33",
    "version": "2026-09-01",
    "type": "email.complained",
    "occurred_at": "2026-09-14T15:04:05+00:00",
    "replayed": false,
    "data": {
        "message_id": "0100019a1b2c3d4e-5f6a7b8c-1234-5678-9abc-def012345678-000000",
        "to": "customer@example.com",
        "from": "hello@yourdomain.com",
        "subject": "Your receipt",
        "status": "complained",
        "tags": {
            "kind": "receipt",
            "order": "1042"
        },
        "detail": {
            "detail": "abuse"
        }
    }
}
```

### email.delayed

The receiving server is refusing mail for now. We keep trying, and a later `email.delivered` or `email.bounced` settles it.

```json
{
    "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33",
    "version": "2026-09-01",
    "type": "email.delayed",
    "occurred_at": "2026-09-14T15:04:05+00:00",
    "replayed": false,
    "data": {
        "message_id": "0100019a1b2c3d4e-5f6a7b8c-1234-5678-9abc-def012345678-000000",
        "to": "customer@example.com",
        "from": "hello@yourdomain.com",
        "subject": "Your receipt",
        "status": "delayed",
        "tags": {
            "kind": "receipt",
            "order": "1042"
        },
        "detail": {
            "detail": "MailboxFull"
        }
    }
}
```

### email.opened

The recipient opened the message. Opens are approximate: privacy features in some mail apps open every message automatically.

```json
{
    "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33",
    "version": "2026-09-01",
    "type": "email.opened",
    "occurred_at": "2026-09-14T15:04:05+00:00",
    "replayed": false,
    "data": {
        "message_id": "0100019a1b2c3d4e-5f6a7b8c-1234-5678-9abc-def012345678-000000",
        "to": "customer@example.com",
        "from": "hello@yourdomain.com",
        "subject": "Your receipt",
        "status": "opened",
        "tags": {
            "kind": "receipt",
            "order": "1042"
        },
        "detail": []
    }
}
```

### email.clicked

The recipient clicked a link. `detail.detail` is the link.

```json
{
    "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33",
    "version": "2026-09-01",
    "type": "email.clicked",
    "occurred_at": "2026-09-14T15:04:05+00:00",
    "replayed": false,
    "data": {
        "message_id": "0100019a1b2c3d4e-5f6a7b8c-1234-5678-9abc-def012345678-000000",
        "to": "customer@example.com",
        "from": "hello@yourdomain.com",
        "subject": "Your receipt",
        "status": "clicked",
        "tags": {
            "kind": "receipt",
            "order": "1042"
        },
        "detail": {
            "detail": "https://yourdomain.com/orders/1042"
        }
    }
}
```

### email.failed

The message never left, because it could not be built or was rejected before sending.

```json
{
    "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33",
    "version": "2026-09-01",
    "type": "email.failed",
    "occurred_at": "2026-09-14T15:04:05+00:00",
    "replayed": false,
    "data": {
        "message_id": "0100019a1b2c3d4e-5f6a7b8c-1234-5678-9abc-def012345678-000000",
        "to": "customer@example.com",
        "from": "hello@yourdomain.com",
        "subject": "Your receipt",
        "status": "failed",
        "tags": {
            "kind": "receipt",
            "order": "1042"
        },
        "detail": {
            "detail": "The template could not be rendered."
        }
    }
}
```

### newsletter.subscriber.subscribed

Someone joined a list, from a form, the API, an import or by hand. On a list that asks people to confirm, `status` is `pending` until they do.

```json
{
    "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33",
    "version": "2026-09-01",
    "type": "newsletter.subscriber.subscribed",
    "occurred_at": "2026-09-14T15:04:05+00:00",
    "replayed": false,
    "data": {
        "email": "sam@example.com",
        "list_id": 42,
        "list_name": "News",
        "status": "subscribed",
        "first_name": "Sam",
        "source": "form"
    }
}
```

### newsletter.subscriber.confirmed

Someone clicked the confirmation link on a list that asks people to confirm.

```json
{
    "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33",
    "version": "2026-09-01",
    "type": "newsletter.subscriber.confirmed",
    "occurred_at": "2026-09-14T15:04:05+00:00",
    "replayed": false,
    "data": {
        "email": "sam@example.com",
        "list_id": 42,
        "list_name": "News",
        "status": "subscribed",
        "first_name": "Sam",
        "source": "form"
    }
}
```

### newsletter.subscriber.unsubscribed

Someone left a list.

```json
{
    "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33",
    "version": "2026-09-01",
    "type": "newsletter.subscriber.unsubscribed",
    "occurred_at": "2026-09-14T15:04:05+00:00",
    "replayed": false,
    "data": {
        "email": "sam@example.com",
        "list_id": 42,
        "list_name": "News",
        "status": "unsubscribed",
        "first_name": "Sam",
        "source": "form"
    }
}
```

### newsletter.preferences.updated

Someone changed which topics they want to hear about on their preferences page. `wanted` is their choice for each topic.

```json
{
    "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33",
    "version": "2026-09-01",
    "type": "newsletter.preferences.updated",
    "occurred_at": "2026-09-14T15:04:05+00:00",
    "replayed": false,
    "data": {
        "email": "sam@example.com",
        "topics": [
            {
                "id": 7,
                "name": "New releases",
                "wanted": true
            },
            {
                "id": 8,
                "name": "Events",
                "wanted": false
            }
        ]
    }
}
```

### inbox.message.received

Mail arrived at one of the business's receiving addresses. `attachment_count` says how many files came with it, and `spam` is our spam verdict. The message itself is in the dashboard inbox.

```json
{
    "id": "6f1c2a4e-3b8d-4f7a-9c21-5d0e8b7a1f33",
    "version": "2026-09-01",
    "type": "inbox.message.received",
    "occurred_at": "2026-09-14T15:04:05+00:00",
    "replayed": false,
    "data": {
        "message_id": "b7d1e0c2-8f3a-4c5e-9d6b-2a1f0e9c8b7a",
        "to": "orders@yourdomain.com",
        "from": "customer@example.com",
        "subject": "Question about order 1042",
        "received_at": "2026-09-14T15:04:05+00:00",
        "attachment_count": 1,
        "spam": false
    }
}
```
