Skip to main content

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 and click Create Webhook, or use the API. To create a webhook via the API, see the code examples below.
#!/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"

Event notification format

Once configured, MailChannels will send event notifications to your webhook in the following format:
[
  {
    "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.
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.
Some event types include additional fields. For the full list, see event types. For operational guidance on failed webhook deliveries and replaying batches, see webhook retries and delivery behavior. For an investigation workflow that starts from a sent message, see debug a sent message.
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.

Retrieving webhook configuration

To view your current webhook configuration:
#!/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"
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.
[
  { "webhook": "https://example.com/webhooks/mailchannels" }
]

Deleting a webhook

To stop receiving event notifications:
#!/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"
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:
#!/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
The response reports whether each webhook returned a 2xx status code:
{
  "all_passed": true,
  "results": [
    {
      "result": "passed",
      "webhook": "https://example.com/webhooks/mailchannels",
      "response": {
        "status": 200,
        "body": "Webhook received successfully"
      }
    }
  ]
}
A test event message will be sent to your webhook containing the following fields:
  • email: “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.
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.
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

1

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 namesig_1738775282. The matching entry in the Signature header uses the same name.
  • Covered components("content-digest"). Only the Content-Digest header was signed.
  • Created1738868393, a Unix timestamp.
  • Algorithmed25519.
  • Key IDmckey. Identifies which public key to use for verification.
2

Check the created timestamp

Reject signatures that are older than a short window (five minutes is a reasonable default) to defend against replay attacks.
3

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.
4

Reconstruct the signing string

Rebuild the exact byte string that MailChannels signed, following the algorithm in RFC 9421, 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.
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.
5

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.

Example signature verification code

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))
}