Webhooks

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

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

{
  "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.
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'));

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.

{
    "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.

{
    "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.

{
    "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.

{
    "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.

{
    "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.

{
    "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.

{
    "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.

{
    "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.

{
    "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.

{
    "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.

{
    "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.

{
    "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
    }
}