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
- Add an address on Notify my app. It has to start with
httpsand be reachable from the public internet. Private and internal addresses are refused. - 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.
- Copy its signing secret into your application.
- Use Send a test to post a real, signed
email.deliveredevent with"test": truein 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": { }
}
idis unique to the event and stays the same on every retry and replay.versionis the shape ofdata. It only changes if a change would break an existing consumer.replayedistruewhen 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
idof 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.deliveredcan occasionally arrive after anemail.openedfor the same message. Useoccurred_atand 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:
- Take the timestamp from
tand the signature fromv1. - Join the timestamp, a full stop and the raw request body, exactly as the bytes arrived, before any JSON parsing.
- Compute HMAC SHA-256 of that string with your signing secret, as lowercase hex.
- Compare it to
v1with a constant-time comparison. - 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
}
}