> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mailchannels.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Learn how to set up and manage webhooks to receive notifications about email delivery events.

## What is a webhook?

A webhook is an HTTP request sent by MailChannels when certain events in the email delivery process occur.

By leveraging these events, you gain real-time insights into email delivery status, improve deliverability, and
enhance your email sending strategies.

## Webhook management

MailChannels provides tools to create, retrieve, delete, and validate webhooks. The first step to receiving webhooks is
to create a webhook.

### Creating a webhook

There are two ways to create a webhook: visit the [webhooks page](https://dash.mailchannels.com/webhooks)
and click **Create Webhook**, or use the API.

To create a webhook via the API, see the code examples below.

<CodeGroup>
  ```bash cURL theme={null}
  #!/usr/bin/env bash
  # Enroll a webhook endpoint to receive delivery events.
    set -u
    : "${MAILCHANNELS_API_KEY:?Set MAILCHANNELS_API_KEY before running}"
    : "${WEBHOOK_ENDPOINT:?Set WEBHOOK_ENDPOINT to the URL that should receive events}"

  curl -X POST "https://api.mailchannels.net/tx/v1/webhook?endpoint=$WEBHOOK_ENDPOINT" \
    -H "X-Api-Key: $MAILCHANNELS_API_KEY"
  ```

  ```javascript Node.js theme={null}
  import { MailChannels } from "mailchannels-sdk";

  const { MAILCHANNELS_API_KEY, WEBHOOK_ENDPOINT } = process.env;
  if (!MAILCHANNELS_API_KEY) throw new Error("Set MAILCHANNELS_API_KEY before running");
  if (!WEBHOOK_ENDPOINT) throw new Error("Set WEBHOOK_ENDPOINT to the URL that should receive events");

  const mailchannels = new MailChannels(MAILCHANNELS_API_KEY);

  const { success, error } = await mailchannels.webhooks.enroll(WEBHOOK_ENDPOINT);

  if (!success) {
    console.error("Enroll webhook failed:", error);
    process.exit(1);
  }
  console.log("Webhook enrolled");
  ```

  ```python Python theme={null}
  import os
  import sys

  import mailchannels

  if not os.environ.get("MAILCHANNELS_API_KEY"):
      sys.exit("Set MAILCHANNELS_API_KEY before running")

  endpoint = os.environ.get("WEBHOOK_ENDPOINT")
  if not endpoint:
      sys.exit("Set WEBHOOK_ENDPOINT to the URL that should receive events")

  # Enroll a webhook endpoint to receive delivery events.
  response = mailchannels.Webhooks.create(endpoint)

  print(response)
  ```

  ```php PHP theme={null}
  <?php

  require_once __DIR__ . '/vendor/autoload.php';

  use MailChannels\Client;

  $api_key  = getenv('MAILCHANNELS_API_KEY') or exit("Error: MAILCHANNELS_API_KEY is not set\n");
  $endpoint = getenv('WEBHOOK_ENDPOINT') or exit("Error: WEBHOOK_ENDPOINT is not set\n");

  $client = new Client(apiKey: $api_key);

  // Enroll a webhook endpoint to receive delivery events.
  $response = $client->webhooks->create($endpoint);

  print_r($response->toArray());
  ```
</CodeGroup>

### Event notification format

Once configured, MailChannels will send event notifications to your webhook in the following format:

```json theme={null}
[
  {
    "email": "sender@example.com",
    "customer_handle": "abc123",
    "timestamp": 1625097600,
    "event": "processed",
    "request_id": "wBLWrCnK0Z965pf-cgxhNg8bo5s="
  },
  {
    "email": "sender@example.com",
    "customer_handle": "abc123",
    "timestamp": 1625098000,
    "event": "delivered",
    "request_id": "wBLWrCnK0Z965pf-cgxhNg8bo5s="
    }
]
```

### Event fields

All event notifications include these common fields:

* `email`: The sender's `From` address.
* `customer_handle`: Your MailChannels account ID. Visible in the upper-right corner of the Console.
* `timestamp`: Unix timestamp of when the event occurred.
* `event`: Event type, such as `processed`, `delivered`, or `hard-bounced`.
* `request_id`: A unique identifier generated to track the original HTTP request.

<Info>
  The `customer_handle` field lets a single webhook receiver handle events for multiple accounts. This is useful if you have
  sub-accounts and want a single webhook receiver for all of them.
</Info>

Some event types include additional fields. For the full list, see [event types](/email-api/webhook-event-types).

For operational guidance on failed webhook deliveries and replaying batches, see
[webhook retries and delivery behavior](/email-api/webhook-retries). For an investigation workflow that starts from a sent
message, see [debug a sent message](/email-api/debug-sent-message).

<Note>
  It is important to make sure your webhook receiver can handle up to 1,000 items in each request body. Some libraries
  may have default limits on the JSON body size or array length. Be sure to adjust those settings if necessary.
</Note>

### Retrieving webhook configuration

To view your current webhook configuration:

<CodeGroup>
  ```bash cURL theme={null}
  #!/usr/bin/env bash
  # List all webhook endpoints currently enrolled for the account.
    set -u
    : "${MAILCHANNELS_API_KEY:?Set MAILCHANNELS_API_KEY before running}"

  curl -X GET "https://api.mailchannels.net/tx/v1/webhook" \
    -H "X-Api-Key: $MAILCHANNELS_API_KEY"
  ```

  ```javascript Node.js theme={null}
  import { MailChannels } from "mailchannels-sdk";

  const { MAILCHANNELS_API_KEY } = process.env;
  if (!MAILCHANNELS_API_KEY) throw new Error("Set MAILCHANNELS_API_KEY before running");

  const mailchannels = new MailChannels(MAILCHANNELS_API_KEY);

  const { data, error } = await mailchannels.webhooks.list();

  if (error) {
    console.error("List webhooks failed:", error);
    process.exit(1);
  }
  console.log(data);
  ```

  ```python Python theme={null}
  import os
  import sys

  import mailchannels

  if not os.environ.get("MAILCHANNELS_API_KEY"):
      sys.exit("Set MAILCHANNELS_API_KEY before running")

  # List all webhook endpoints currently enrolled for the account.
  response = mailchannels.Webhooks.list()

  print(response)
  ```

  ```php PHP theme={null}
  <?php

  require_once __DIR__ . '/vendor/autoload.php';

  use MailChannels\Client;

  $api_key = getenv('MAILCHANNELS_API_KEY') or exit("Error: MAILCHANNELS_API_KEY is not set\n");

  $client = new Client(apiKey: $api_key);

  // List all webhook endpoints currently enrolled for the account.
  $response = $client->webhooks->list();

  print_r($response->toArray());
  ```
</CodeGroup>

A successful call returns **200 OK** with a JSON array of enrolled webhook endpoints. At this time, only one webhook can
be configured per account.

<CodeGroup>
  ```json Response theme={null}
  [
    { "webhook": "https://example.com/webhooks/mailchannels" }
  ]
  ```
</CodeGroup>

### Deleting a webhook

To stop receiving event notifications:

<CodeGroup>
  ```bash cURL theme={null}
  #!/usr/bin/env bash
  # Delete all webhook endpoints enrolled for the account.
    set -u
    : "${MAILCHANNELS_API_KEY:?Set MAILCHANNELS_API_KEY before running}"

  curl -X DELETE "https://api.mailchannels.net/tx/v1/webhook" \
    -H "X-Api-Key: $MAILCHANNELS_API_KEY"
  ```

  ```javascript Node.js theme={null}
  import { MailChannels } from "mailchannels-sdk";

  const { MAILCHANNELS_API_KEY } = process.env;
  if (!MAILCHANNELS_API_KEY) throw new Error("Set MAILCHANNELS_API_KEY before running");

  const mailchannels = new MailChannels(MAILCHANNELS_API_KEY);

  const { success, error } = await mailchannels.webhooks.deleteAll();

  if (!success) {
    console.error("Delete webhooks failed:", error);
    process.exit(1);
  }
  console.log("All webhooks deleted");
  ```

  ```python Python theme={null}
  import os
  import sys

  import mailchannels

  if not os.environ.get("MAILCHANNELS_API_KEY"):
      sys.exit("Set MAILCHANNELS_API_KEY before running")

  # Delete all webhook endpoints enrolled for the account.
  response = mailchannels.Webhooks.delete_all()

  print(response)
  ```

  ```php PHP theme={null}
  <?php

  require_once __DIR__ . '/vendor/autoload.php';

  use MailChannels\Client;

  $api_key = getenv('MAILCHANNELS_API_KEY') or exit("Error: MAILCHANNELS_API_KEY is not set\n");

  $client = new Client(apiKey: $api_key);

  // Delete all webhook endpoints enrolled for the account.
  $response = $client->webhooks->deleteAll();

  print_r($response->toArray());
  ```
</CodeGroup>

A successful call returns **204 No Content** with an empty body.

### Validating webhook configuration

To verify your current webhook setup, send a test event to each enrolled webhook:

<CodeGroup>
  ```bash cURL theme={null}
  #!/usr/bin/env bash
  # Send a test event to each enrolled webhook and report whether it responded
  # with a 2xx status. The request_id field is optional; if omitted, one is
  # generated automatically.
    set -u
    : "${MAILCHANNELS_API_KEY:?Set MAILCHANNELS_API_KEY before running}"

  curl -X POST "https://api.mailchannels.net/tx/v1/webhook/validate" \
    -H "Content-Type: application/json" \
    -H "X-Api-Key: $MAILCHANNELS_API_KEY" \
    -d @- <<JSON
  {
    "request_id": "test_request_1"
  }
  JSON
  ```

  ```javascript Node.js theme={null}
  import { MailChannels } from "mailchannels-sdk";

  const { MAILCHANNELS_API_KEY } = process.env;
  if (!MAILCHANNELS_API_KEY) throw new Error("Set MAILCHANNELS_API_KEY before running");

  const mailchannels = new MailChannels(MAILCHANNELS_API_KEY);

  const { data, error } = await mailchannels.webhooks.validate();

  if (error) {
    console.error("Validate webhook failed:", error);
    process.exit(1);
  }
  console.log(data);
  ```

  ```python Python theme={null}
  import os
  import sys

  import mailchannels

  if not os.environ.get("MAILCHANNELS_API_KEY"):
      sys.exit("Set MAILCHANNELS_API_KEY before running")

  # Send a test event to each enrolled webhook and report whether it responded
  # with a 2xx status. request_id is optional; if omitted, one is generated.
  response = mailchannels.Webhooks.validate(request_id="test_request_1")

  print(response)
  ```

  ```php PHP theme={null}
  <?php

  require_once __DIR__ . '/vendor/autoload.php';

  use MailChannels\Client;

  $api_key = getenv('MAILCHANNELS_API_KEY') or exit("Error: MAILCHANNELS_API_KEY is not set\n");

  $client = new Client(apiKey: $api_key);

  // Send a test event to each enrolled webhook and report whether it responded
  // with a 2xx status. request_id is optional; if omitted, one is generated.
  $response = $client->webhooks->validate(requestId: 'test_request_1');

  print_r($response->toArray());
  ```
</CodeGroup>

The response reports whether each webhook returned a 2xx status code:

<CodeGroup>
  ```json Response theme={null}
  {
    "all_passed": true,
    "results": [
      {
        "result": "passed",
        "webhook": "https://example.com/webhooks/mailchannels",
        "response": {
          "status": 200,
          "body": "Webhook received successfully"
        }
      }
    ]
  }
  ```
</CodeGroup>

A test event message will be sent to your webhook containing the following fields:

* `email`: "[test@mailchannels.com](mailto:test@mailchannels.com)".
* `customer_handle`: Your account ID.
* `timestamp`: Unix timestamp of the test event.
* `event`: "test".
* `request_id`: Either provided by you or generated automatically.
* `smtp_id`: Generated automatically.

## Best practices

1. Verify the message signature.
2. Ensure your webhook endpoint can handle concurrent requests.
3. Process events asynchronously to avoid blocking the webhook receiver.
4. Implement retry logic in case of temporary failures.
5. Store raw event data before processing to allow for reprocessing if needed.

## Storing webhook data

Treat webhook storage as part of your email system, not as an afterthought. A durable event table lets you answer support
questions, rebuild derived state, and satisfy audit requirements.

At minimum, store:

* The full raw JSON payload.
* The event type.
* The event timestamp.
* The `customer_handle`.
* The `request_id` and `smtp_id`, when present.
* Your processing status, such as `received`, `processed`, or `failed`.

<Tip>
  A common pattern is to acknowledge the webhook quickly, enqueue the raw event for background processing, and update the
  processing status after your worker finishes.
</Tip>

Plan your retention period intentionally. Operational debugging may only need recent events, while compliance or customer
support workflows may require longer retention.

## Verifying message signatures

All webhooks are signed by default. There are three HTTP headers to consider during the signature verification process:

* Content-Digest: hash of the message body
* Signature-Input: describes what parts of the message are signed, along with other data about the signing method
* Signature: the cryptographic signature

### How to verify a message

<Steps>
  <Step title="Parse the Signature-Input header">
    The `Signature-Input` header describes how the request was signed and which parts of it were covered. For example:

    ```
    Signature-Input: sig_1738775282=("content-digest");created=1738868393;alg="ed25519";keyid="mckey"
    ```

    From this header you can extract:

    * **Signature name** — `sig_1738775282`. The matching entry in the `Signature` header uses the same name.
    * **Covered components** — `("content-digest")`. Only the `Content-Digest` header was signed.
    * **Created** — `1738868393`, a Unix timestamp.
    * **Algorithm** — `ed25519`.
    * **Key ID** — `mckey`. Identifies which public key to use for verification.
  </Step>

  <Step title="Check the created timestamp">
    Reject signatures that are older than a short window (five minutes is a reasonable default) to defend against replay attacks.
  </Step>

  <Step title="Retrieve the public key">
    Fetch the public key from MailChannels using the `keyid` from step 1:

    ```
    GET https://api.mailchannels.net/tx/v1/webhook/public-key?id=mckey
    ```

    Cache the response and reuse it for subsequent requests. You only need to refetch when you see a `keyid` you haven't
    encountered before.
  </Step>

  <Step title="Reconstruct the signing string">
    Rebuild the exact byte string that MailChannels signed, following the algorithm in [RFC 9421, Section 2.5](https://www.rfc-editor.org/rfc/rfc9421.html#section-2.5). For our example, the signing string is:

    ```
    "content-digest": sha-256=:6R+3pwkD8ueMsjjr7Q6+7Zvj9BhpMJKHEAqpc1YRxi0=:
    "@signature-params": ("content-digest");created=1738868393;alg=ed25519;keyid=mckey
    ```

    The rules are:

    * For each covered component, write the lowercased name in quotes, then `: `, then the header value, on a single line.
    * Append a final `"@signature-params"` line with the parenthesised component list followed by the remaining `Signature-Input` parameters.
    * Join lines with a single `\n` and do not include a trailing newline.

    <Warning>
      Use an HTTP Message Signatures library that implements RFC 9421 rather than building this string by hand. Small deviations
      (whitespace, header casing, trailing newlines) cause verification to fail silently.
    </Warning>
  </Step>

  <Step title="Verify the signature">
    The `Signature` header carries each signature as `<name>=:<base64-value>:`. For example:

    ```
    Signature: sig_1738775282=:YtB126we6ICgMHHjLg...:
    ```

    Base64-decode the value between the colons, then verify the resulting bytes against the signing string from step 4 using the public key and the `ed25519` algorithm.
  </Step>
</Steps>

## Example signature verification code

<CodeGroup>
  ```go Go theme={null}
  package main

  import (
  	"crypto/ed25519"
  	"crypto/x509"
  	"encoding/json"
  	"encoding/pem"
  	"fmt"
  	"io"
  	"log"
  	"net/http"
  	"net/url"
  	"time"

  	"github.com/yaronf/httpsign"
  )

  const (
  	ExpectedCustomerHandle = "myaccountid"
  )

  type Handler struct {
  	Verifier httpsign.Verifier
  }

  type WebhookPayload struct {
  	Email          string `json:"email"`
  	CustomerHandle string `json:"customer_handle"`
  	Timestamp      int64  `json:"timestamp"`
  	Event          string `json:"event"`
  	RequestID      string `json:"request_id"`
  }

  func (h Handler) webhookHandler(w http.ResponseWriter, r *http.Request) {
  	if r.Method != http.MethodPost {
  		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
  		return
  	}

  	body, err := io.ReadAll(r.Body)
  	if err != nil {
  		http.Error(w, "Error reading request body", http.StatusInternalServerError)
  		return
  	}
  	defer r.Body.Close()

  	var payloads []WebhookPayload
  	err = json.Unmarshal(body, &payloads)
  	if err != nil {
  		http.Error(w, "Error parsing JSON", http.StatusBadRequest)
  		return
  	}

  	err = httpsign.VerifyRequest("sig_1738775282", h.Verifier, r)
  	fmt.Printf("verified: %t\n", err == nil)
  	if err != nil {
  		fmt.Printf("Error verifying request: %s\n", err)
  		http.Error(w, "Error verifying request", http.StatusInternalServerError)
  		return
  	}

  	for _, payload := range payloads {
  		// Verify customer handle
  		if payload.CustomerHandle != ExpectedCustomerHandle {
  			http.Error(w, "Invalid customer handle", http.StatusForbidden)
  			return
  		}

  		// Validate required fields
  		if payload.CustomerHandle == "" || payload.Timestamp == 0 || payload.Event == "" {
  			http.Error(w, "Missing required fields", http.StatusBadRequest)
  			return
  		}

  		// Validate event type
  		validEvents := map[string]bool{
  			"open":         true,
  			"click":        true,
  			"processed":    true,
  			"dropped":      true,
  			"delivered":    true,
  			"hard-bounced": true,
  			"soft-bounced": true,
  			"unsubscribed": true,
  		}
  		if !validEvents[payload.Event] {
  			http.Error(w, "Invalid event type", http.StatusBadRequest)
  			return
  		}

  		// Process the webhook payload
  		fmt.Printf("Received webhook: Email: %s, Customer: %s, Event: %s, Id: %s, Time: %s\n",
  			payload.Email,
  			payload.CustomerHandle,
  			payload.Event,
  			payload.RequestID,
  			time.Unix(payload.Timestamp, 0).Format(time.RFC3339))
  	}

  	w.WriteHeader(http.StatusOK)
  	w.Write([]byte("Webhook received successfully"))
  }

  func parsePEMED25519PublicKey(pemData string) (ed25519.PublicKey, error) {
  	block, _ := pem.Decode([]byte(pemData))
  	if block == nil {
  		return nil, fmt.Errorf("failed to decode PEM block")
  	}

  	pubKey, err := x509.ParsePKIXPublicKey(block.Bytes)
  	if err != nil {
  		return nil, fmt.Errorf("failed to parse public key: %w", err)
  	}

  	edPubKey, ok := pubKey.(ed25519.PublicKey)
  	if !ok {
  		return nil, fmt.Errorf("not a valid Ed25519 public key")
  	}

  	return edPubKey, nil
  }

  func retrievePublicKey(baseURL, keyID string) (string, error) {
  	endpoint := fmt.Sprintf("%s/webhook/public-key", baseURL)
  	params := url.Values{}
  	params.Add("id", keyID)

  	fullURL := fmt.Sprintf("%s?%s", endpoint, params.Encode())
  	resp, err := http.Get(fullURL)
  	if err != nil {
  		return "", fmt.Errorf("failed to make request: %w", err)
  	}
  	defer resp.Body.Close()

  	switch resp.StatusCode {
  	case http.StatusOK:
  		var key struct {
  			ID  string `json:"id"`
  			Key string `json:"key"`
  		}
  		if err := json.NewDecoder(resp.Body).Decode(&key); err != nil {
  			return "", fmt.Errorf("failed to decode response: %w", err)
  		}
  		return key.Key, nil
  	case http.StatusNotFound:
  		return "", fmt.Errorf("key not found")
  	case http.StatusInternalServerError:
  		return "", fmt.Errorf("internal server error")
  	default:
  		return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
  	}
  }

  func main() {
  	baseURL := "https://api.mailchannels.net/tx/v1/"
  	keyID := "mckey"

  	publicKeyStr, err := retrievePublicKey(baseURL, keyID)
  	if err != nil {
  		log.Fatalf("error retrieving public key: %s", err)
  	}

  	publicED25519Key, err := parsePEMED25519PublicKey(publicKeyStr)
  	if err != nil {
  		log.Fatalf("error parsing public key: %s", err)
  	}

  	config := httpsign.NewVerifyConfig().SetKeyID(keyID).SetVerifyCreated(true).SetNotOlderThan(5 * time.Minute)
  	verifier, _ := httpsign.NewEd25519Verifier(publicED25519Key, config, httpsign.Headers("Content-Digest"))

  	h := Handler{Verifier: *verifier}

  	http.HandleFunc("/", h.webhookHandler)
  	fmt.Println("Starting server on :8080")
  	log.Fatal(http.ListenAndServe(":8080", nil))
  }
  ```

  ```javascript Node.js theme={null}
  import express from 'express';
  import { Webhooks } from 'mailchannels-sdk';

  const app = express();
  const port = 3000;

  const EXPECTED_CUSTOMER_HANDLE = 'myaccountid';

  app.use(express.json({
    limit: '5mb',
    verify: (req, res, buf) => {
      req.rawBody = buf.toString();
    }
  }));

  app.post('/', async (req, res) => {
    const { error } = await Webhooks.verify({
      payload: req.rawBody,
      headers: req.headers,
    });

    if (error) {
      console.error('Webhook verification failed:', error.message);
      return res.status(403).json({ error: error.message });
    }

    for (const payload of req.body) {
      const { email, customer_handle, timestamp, event, request_id } = payload;

      if (!customer_handle || !timestamp || !event) {
        return res.status(400).json({ error: 'Missing required fields' });
      }

      if (customer_handle !== EXPECTED_CUSTOMER_HANDLE) {
        return res.status(403).json({ error: 'Invalid customer handle' });
      }

      console.log(`Received webhook: Email: ${email}, Customer: ${customer_handle}, Event: ${event}, ID: ${request_id}, Time: ${new Date(timestamp * 1000).toISOString()}`);
    }

    res.status(200).json({ message: 'Webhook received successfully' });
  });

  app.listen(port, () => {
    console.log(`Webhook receiver listening at http://localhost:${port}`);
  });
  ```

  ```python Python theme={null}
  from flask import Flask, request

  import mailchannels

  app = Flask(__name__)

  EXPECTED_CUSTOMER_HANDLE = "myaccountid"
  VALID_EVENTS = {
      "open",
      "click",
      "processed",
      "delivered",
      "dropped",
      "hard-bounced",
      "soft-bounced",
      "unsubscribed",
  }

  # Fetch the public key once at startup. In a long-running service, refresh
  # this if the `keyid` parameter in incoming Signature-Input headers changes.
  public_key = mailchannels.Webhooks.public_key("mckey").key


  @app.post("/")
  def webhook():
      raw_body = request.get_data()
      headers = dict(request.headers)

      # Verifies Content-Digest, Signature-Input freshness, and the Ed25519 signature.
      if not mailchannels.verify_webhook_signature(headers, raw_body, public_key):
          return {"error": "Invalid signature"}, 403

      payloads = request.get_json()
      if not isinstance(payloads, list):
          return {"error": "Payload must be an array"}, 400

      for payload in payloads:
          customer_handle = payload.get("customer_handle")
          timestamp = payload.get("timestamp")
          event = payload.get("event")

          if not customer_handle or not timestamp or not event:
              return {"error": "Missing required fields"}, 400
          if customer_handle != EXPECTED_CUSTOMER_HANDLE:
              return {"error": "Invalid customer handle"}, 403
          if event not in VALID_EVENTS:
              return {"error": "Invalid event type"}, 400

          print(f"Received webhook: {payload}")

      return {"message": "Webhook received successfully"}, 200


  if __name__ == "__main__":
      app.run(port=3000)
  ```
</CodeGroup>
