# Webhooks

Get notified in your own system when e-mail you sent is delivered, bounces, or is opened or clicked.

## Overview

Make sends an HTTP POST with a JSON payload to each endpoint you configure. An endpoint subscribes to one or more event types, and several endpoints can subscribe to the same event.

Set them up under Settings → Webhooks. You can have up to 16 endpoints. URLs must use HTTPS and cannot point to private or internal addresses. An endpoint covers either all your domains or a single sending domain, and the same URL can be registered once for all domains and once per domain.

## Event types

| Event | Sent when |
| --- | --- |
| `accepted` | Make accepted the message and queued it for sending. |
| `delivered` | The message was delivered to the recipient's mail server. |
| `permanent_fail` | Hard bounce. The address does not exist or rejected the message permanently. |
| `temporary_fail` | Soft bounce. The failure is temporary and delivery is retried automatically. |
| `opened` | The recipient opened the message. Requires open tracking. |
| `clicked` | The recipient clicked a link. Requires click tracking. |
| `unsubscribed` | The recipient clicked the unsubscribe link. Not sent yet, coming in a later release. |
| `complained` | The recipient marked the message as spam (feedback loop). |

Events are sent within about a minute. A webhook only receives events registered after it was created, and events registered while it is disabled are not sent when it is enabled again.

## Payload format

Every request is a POST with `Content-Type: application/json` and two top-level keys: signature and event-data.

Example · delivered:

```json
{
  "signature": {
    "timestamp": "1782555000",
    "token": "a3f9c1e0b27d4f8a6c1e9b0d4a7f2c8e",
    "signature": "d8e1f0a4c9b27e5f1a3d6c0b8e4f2a17c5d9b0e3f6a1c4d7e8b2f0a9c3d6e1b4"
  },
  "event-data": {
    "event": "delivered",
    "timestamp": 1782554998.0,
    "id": "delivered-4821",
    "job-id": "3714",
    "recipient": "user@example.com",
    "message": {
      "headers": {
        "message-id": "20260827.abc123@yourdomain.com",
        "subject": "Your monthly report",
        "from": "Newsletter <post@yourdomain.com>"
      }
    },
    "delivery-status": {
      "code": "2.0.0",
      "mta": "mx.example.com"
    }
  }
}
```

- **event:** The event type.
- **timestamp:** When it happened, as a Unix timestamp with fractional seconds.
- **id:** A stable unique ID. Use it to remove duplicates, since a retry can deliver an event more than once.
- **job-id:** The ID of the submission. It matches the `X-Job-ID` header and the SMTP "queued as" reply.
- **recipient:** The address the event is about.
- **message.headers:** `message-id`, subject and from of the original e-mail. Left out for opened and clicked.

## Event-specific fields

- **permanent_fail / temporary_fail:** severity (permanent or temporary), reason (for example bad-mailbox or quota-issues), and delivery-status with code (a DSN such as 5.1.1), message and mta.
- **delivered:** delivery-status with code, message and mta.
- **opened:** ip and user-agent of the client.
- **clicked:** The url that was clicked, plus ip and user-agent.
- **complained:** feedback-type (usually abuse), and reason when available.

## Verifying signatures

Every account has a signing key under Settings → Webhooks. `signature.signature` is an HMAC-SHA256 hex digest of `timestamp + token`, made with your signing key.

Ruby:

```ruby
def valid_signature?(signature_block, signing_key)
  expected = OpenSSL::HMAC.hexdigest(
    'SHA256',
    signing_key,
    signature_block['timestamp'] + signature_block['token']
  )
  ActiveSupport::SecurityUtils.secure_compare(expected, signature_block['signature'])
end
```

Node.js:

```js
const crypto = require('crypto')

function validSignature({ timestamp, token, signature }, signingKey) {
  const expected = crypto
    .createHmac('sha256', signingKey)
    .update(timestamp + token)
    .digest('hex')
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
}
```

Python:

```python
import hashlib
import hmac

def valid_signature(signature_block, signing_key):
    expected = hmac.new(
        signing_key.encode(),
        (signature_block['timestamp'] + signature_block['token']).encode(),
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature_block['signature'])
```

PHP:

```php
<?php
function validSignature(array $signature, string $signingKey): bool
{
    $expected = hash_hmac('sha256', $signature['timestamp'] . $signature['token'], $signingKey);
    return hash_equals($expected, $signature['signature']);
}
```

- Reject requests whose timestamp is more than a few minutes old, to protect against replays.
- For stronger protection, remember the token values you have seen in that window and reject duplicates.
- Compare signatures in constant time, as the examples do.
- If you generate a new signing key, update your integrations straight away.

## Delivery, retries and failures

- A delivery succeeds when your endpoint returns a 2xx response within the timeout: 5 s to connect and 10 s to respond.
- Redirects are not followed and count as failures.
- Failed deliveries are retried up to 5 times, with increasing backoff.
- After 25 failed attempts in a row with all retries used, the webhook is disabled automatically. Enable it again under Settings → Webhooks.
- The delivery log keeps the latest 50 attempts per endpoint, with response codes, durations and full payloads.

Make your endpoint idempotent, so two deliveries with the same `event-data.id` count as one event. The order is not guaranteed, so sort by `event-data.timestamp` rather than by arrival time.

## Test notifications

Send test notification on a webhook sends a sample payload for every event type it subscribes to. Test requests carry the header `X-Webhook-Test: true` and a `"test": true` field in event-data, and are marked as tests in the delivery log.
