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
accepteddeliveredpermanent_failtemporary_failopenedclickedunsubscribedcomplainedEvents 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.
{
"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-IDheader 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.
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
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)) }
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 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.