Make Sender · Guide

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