Delivery Events
After you submit an email message to Email API via the /send
endpoint, the
message is queued for delivery within MailChannels' extensive cloud
infrastructure. When something happens to your message, Email API can send you
a webhook callback allowing you to track message delivery attempts and failures
(i.e. bounces), user interactions such as opens and clicks, and more.
Webhook Management
Webhooks are managed via the /webhook
endpoint. The HTTP request method
specifies whether you are creating, retrieving, or removing a webhook:
POST
: Create a webhook to receive delivery eventsGET
: Retrieve an existing webhook configurationDELETE
: Remove an existing webhook configuration
Creating a Webhook
To create a webhook that will receive delivery events from Email API, send a
POST
request to /webhook
specifying the URL at which you wish to receive
webhook requests from Email API:
curl -X POST 'https://api.mailchannels.net/tx/v1/webhook?endpoint=YOUR_ENDPOINT_URL' \
-H 'X-Api-Key: YOUR_API_KEY'
Replace YOUR_ENDPOINT_URL
with your webhook receiver URL and YOUR_API_KEY
with your MailChannels API key.
Event Notification Format
Once configured, MailChannels will send batched 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": 162509800,
"event": "delivered",
"request_id": "wBLWrCnK0Z965pf-cgxhNg8bo5s="
}
]
See the Appendix below for a JSON schema definition.
Event Fields
email
: Sender email addresscustomer_handle
: the account ID that you can find in the upper-right corner of the MailChannels Consoletimestamp
: Unix timestamp of the eventevent
: Event type, such asdelivered
orhard-bounced
(see Supported Event Types below)request_id
: A unique identifier generated to track the original HTTP request
The
customer_handle
is set so that you can have a single webhook receiver that receives webhooks for multiple MailChannels accounts. For instance, if your account ID isexampleuser
then thecustomer_handle
field will be set toexampleuser
for webhooks related to emails sent by that MailChannels account.
To improve your webhook security, we highly recommend verifying that the customer_handle
value matches your account ID.
To find your account ID, go to the upper-right corner of the MailChannels Console
Event fields may vary depending on the event type. For details, see Supported Event Types below.
Retrieving Webhook Configuration
To view your current webhook configuration:
curl -X GET 'https://api.mailchannels.net/tx/v1/webhook' \
-H 'X-Api-Key: YOUR_API_KEY'
Deleting a Webhook
To stop receiving event notifications:
curl -X DELETE 'https://api.mailchannels.net/tx/v1/webhook' \
-H 'X-Api-Key: YOUR_API_KEY'
Supported Event Types
Event Type | Description |
---|---|
processed | System received and began processing email for delivery |
delivered | Recipient's server accepted the email |
dropped | System encountered an error when processing the email |
unsubscribed | Recipient clicked the unsubscribe link in the email |
open* | Recipient opened the email |
click* | Recipient clicked a link in the email |
hard-bounced* | Recipient's server rejected the email |
complained* | Recipient filed a spam complaint about the email |
open
and click
events require tracking to be enabled, which is not available in our free tier.
complained
is reserved for future use.
Hard-bounced Event
We monitor email traffic and deliver a hard-bounced event for each message with unreachable recipients, adding those recipients to your suppression list. Such recipients can be retrieved via the GET /suppression-list endpoint.
However, in certain cases, we are unable to deliver a hard-bounced event:
- Some SMTP servers do not specify which recipients are rejected. If only some recipients reject the message and we can't identify them, no hard-bounce event will be delivered.
- If the recipient's SMTP server rejects the message before we begin sending the message content, we cannot associate the failure with your account, so no hard-bounced event will be delivered.
Fields for hard-bounced events
email
: Sender email address.customer_handle
: Your account ID.timestamp
: Unix timestamp of the event.event
: The type of event,hard-bounced
in this case.recipients
: List of recipient email addresses that reject the message.status
: The final SMTP status code received during the delivery attempt. This is usually the code that causes the bounce. This may be a successful (2xx) code if the message is delivered to some recipients but rejected by others.reason
(optional): A human-readable explanation of why the message bounces. This may be blank if the message is rejected by some recipients but delivered to others.request_id
(optional): A unique identifier generated to track the original HTTP request. This will be blank if the message is sent via SMTP instead of the API, or if it bounces before headers are processed.smtp_id
(optional): A unique identifier used to track the message, matching theMessage-Id
header. This will be blank if the message bounces before headers are processed.
Best Practices
- Ensure request integrity by verifying the message signature.
- Ensure your webhook endpoint can handle concurrent requests.
- Authenticate our requests by checking that each request contains your account ID in the
customer_handle
field. - Process events asynchronously to avoid blocking the webhook receiver.
- Implement retry logic in case of temporary failures.
- Store raw event data before processing to allow for reprocessing if needed.
By leveraging delivery events, you can gain real-time insights into email delivery status, improve deliverability, and enhance your email sending strategies.
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
-
Extract signature information from the Signature-Input header
- From this header you can find the name of the key used to sign the message, the name of the signature, when the signature was created, and the parts of the message that were signed.
- Here is an example Signature-Input header:
Signature-Input: sig_1738775282=("content-digest");created=1738868393;alg="ed25519";keyid="mckey"
- Using this example, we can see the following information:
ⅰ. A signature with namesig_1738775282
was created for the Content-Digest header
ⅱ. The HTTP request was signed at this time:1738868393
. This is a unix timestamp.
ⅲ. The algorithm used to sign the HTTP request wased25519
ⅳ. The ID of the key used to sign the request ismckey
-
Check the created timestamp. If too much time has passed, consider rejecting the message in order to prevent replay attacks.
-
Retrieve the public key
- This can be done by querying the following endpoint:
https://api.mailchannels.net/tx/v1/webhook/public-key?id=<the id from step 1>
- Continuing with our example from above, the endpoint would be
https://api.mailchannels.net/tx/v1/webhook/public-key?id=mckey
- This can be done by querying the following endpoint:
-
Recreate the string used for signing
- RFC 9421 goes into detail about the algorithm used to create the string prior to signing.
- In our example, we take the Content-Digest header, lowercase the header name, and include it on a single line.
- Then, add
"@signature-params": ("content-digest")
and the rest of the Signature-Input on one line. - The finished string looks like this
"content-digest": sha-256=:6R+3pwkD8ueMsjjr7Q6+7Zvj9BhpMJKHEAqpc1YRxi0=:
"@signature-params": ("content-digest");created=1738868393;alg=ed25519;keyid=mckey
- It is important to follow the exact algorithm for creating the string as specified in RFC 9421. MailChannels recommends using a library to help with signature verification.
-
Verify the signature
- base64 decode the value of the Signature header
- Here is an example Signature header:
Signature: sig_1738775282=:b//YtB126we6ICgMHHjLg
- Using this example, base64 decode this portion:
YtB126we6ICgMHHjLg
- Use the public key to verify the signature
The following code examples include verification steps.
Appendix: Sample Webhook Code
- Go
- JavaScript
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,
"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))
}
const express = require('express');
const axios = require('axios');
const bodyParser = require('body-parser');
const crypto = require('crypto');
const app = express();
const port = 3000;
const EXPECTED_CUSTOMER_HANDLE = 'myaccountid';
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString();
}
}));
// Webhook route
app.post('/webhook', (req, res) => {
const payloads = req.body;
if (!Array.isArray(payloads)) {
return res.status(400).json({ error: 'Payload must be an array' });
}
if (!verifySignature(req, req.rawBody)) {
console.error("invalid signature")
return res.status(403)
}
console.log("Signature verified successfully");
for (var payload of payloads) {
var {email, customer_handle, timestamp, event, request_id} = payload;
// Validate required fields
if (!customer_handle || !timestamp || !event) {
return res.status(400).json({error: 'Missing required fields'});
}
// Verify customer handle
if (customer_handle !== EXPECTED_CUSTOMER_HANDLE) {
return res.status(403).json({error: 'Invalid customer handle'});
}
// Validate event type
const validEvents = ['open', 'click', 'processed', 'delivered', 'dropped', 'hard-bounced', 'unsubscribed'];
if (!validEvents.includes(event)) {
return res.status(400).json({error: 'Invalid event type'});
}
// Process the webhook payload
console.log(`Received webhook: Email: ${email}, Customer: ${customer_handle}, Event: ${event}, ID: ${request_id}, Time: ${new Date(timestamp * 1000).toISOString()}`);
// Here you could add additional processing logic if needed
}
res.status(200).json({ message: 'Webhook received successfully' });
});
let publicKey = null;
const publicKeyPromise = fetchWebhookKey('mckey');
publicKeyPromise.then((k) => {
publicKey = k;
app.listen(port, () => {
console.log(`Webhook receiver listening at http://localhost:${port}`);
});
});
function verifySignature(req, payloads) {
// There can be multiple Content-Digest, Signature, and Signature-Input headers.
// This sample code expects one each
const contentDigest = req.headers['content-digest'];
const signature = req.headers['signature'];
const signatureInput = req.headers['signature-input'];
if (!validateContentDigest(contentDigest, payloads)) {
console.error('Invalid Content-Digest');
return false;
}
var signatureInputObj = parseSignatureInput(signatureInput);
// consider a shorter or longer window depending on your use case. this is for five minutes (300000 ms)
if(!isCreatedWithinWindow(signatureInputObj.createdTimestamp, 300000)) {
console.error('Signature Input is not within 5 minutes');
return false;
}
// whitespace is important here. we want a newline after the content-digest, immediately followed by
// the signature-params (no tabs, no spaces)
const signingString = `"content-digest": ${contentDigest}
"@signature-params": ("content-digest");created=${signatureInputObj.createdTimestamp};alg="${signatureInputObj.algorithm}";keyid="${signatureInputObj.keyID}"`;
const parsedSignature = parseSignature(signature);
if (!verifyEd25519Signature(signingString, parsedSignature, publicKey)) {
console.log('Invalid Signature');
return false;
}
return true;
}
function validateContentDigest(contentDigestHeader, body) {
if (!contentDigestHeader) {
console.error("Missing Content-Digest header.");
return false;
}
// There can be multiple Content-Digest headers, this sample code expects one
const match = contentDigestHeader.match(/^(.*?)=:(.*?):$/i);
if (!match) {
console.error("Invalid Content-Digest format.");
return false;
}
const [, algorithm, providedDigest] = match;
const normalizedAlgorithm = algorithm.replace('-', '').toLowerCase();
if (!crypto.getHashes().includes(normalizedAlgorithm)) {
console.error(`Unsupported hash algorithm: ${algorithm}`);
return false;
}
const hash = crypto.createHash(normalizedAlgorithm).update(body).digest('base64');
const isValid = hash === providedDigest;
if (!isValid) {
console.error(`Digest mismatch!\nExpected: ${hash}\nProvided: ${providedDigest}`);
}
return isValid;
}
async function fetchWebhookKey(keyId) {
const url = `https://api.mailchannels.net/tx/v1/webhook/public-key?id=${encodeURIComponent(keyId)}`;
try {
const response = await axios.get(url);
if (!response.status === 200) {
throw new Error(`non-successful http response retrieving webhook public key: ${response.status}`);
}
const data = response.data;
return data.key;
} catch (error) {
console.error("Error fetching webhook key:", error);
return null;
}
}
function parseSignatureInput(signatureInput) {
const regex = /^(\w+)=\(([^)]+)\);created=(\d+);alg="([^"]+)";keyid="([^"]+)"$/;
const match = signatureInput.match(regex);
if (!match) {
console.error("Invalid Signature-Input format.");
return null;
}
return {
signatureName: match[1], // Extracts 'sig_1738775282'
createdTimestamp: parseInt(match[3], 10), // Extracts 1738868393
algorithm: match[4], // Extracts 'ed25519
keyID: match[5] // Extracts 'mckey'
};
}
function parseSignature(signatureString) {
const match = signatureString.match(/:(.+):/);
if (!match) {
console.error("Invalid signature format.");
return null;
}
let extractedSignature = match[1];
if (extractedSignature.startsWith('b//')) {
extractedSignature = extractedSignature.slice(3);
}
return extractedSignature;
}
function verifyEd25519Signature(message, signature, publicKeyPEM) {
try {
const msgBuffer = Buffer.isBuffer(message) ? message : Buffer.from(message, 'utf-8');
const sigBuffer = Buffer.isBuffer(signature) ? signature : Buffer.from(signature, 'base64');
if (!publicKeyPEM.includes('-----BEGIN PUBLIC KEY-----')) {
console.error("Invalid PEM public key format.");
return false;
}
const keyObject = crypto.createPublicKey(publicKeyPEM);
return crypto.verify(null, msgBuffer, keyObject, sigBuffer);
} catch (error) {
console.error("Error verifying Ed25519 signature:", error.message);
return false;
}
}
function isCreatedWithinWindow(timestamp, windowMs) {
const currentTime = Date.now();
// Convert created timestamp to milliseconds
const createdTime = timestamp * 1000;
return (currentTime - createdTime) < windowMs;
}
Appendix: JSON Schema
The following JSON Schema describes the data structure that will be sent to your webhook by Email API.
The event type complained
is reserved for future use.
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "array",
"items": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"description": "The sender's email address"
},
"customer_handle": {
"type": "string",
"description": "The MailChannels account ID that generated the webhook. If the message was sent by a sub-account, this field contains the sub-account handle."
},
"timestamp": {
"type": "integer",
"description": "The Unix timestamp (in seconds) when the event occurred; the timezone is always UTC"
},
"event": {
"type": "string",
"enum": ["processed", "delivered", "open", "click", "hard-bounced", "dropped", "complained", "unsubscribed"],
"description": "The type of event that occurred"
},
"request_id": {
"type": "string",
"description": "A unique identifier generated to track the original HTTP request"
},
"status": {
"type": "string",
"description": "For hard-bounced and dropped events, the SMTP status code that caused the bounce"
},
"reason": {
"type": "string",
"description": "For hard-bounced and dropped events, a human-readable explanation of why the message hard-bounced"
},
"campaign_id": {
"type": "string",
"description": "The campaign identifier for the message that generated the event"
},
"url": {
"type": "string",
"description": "For click events, the URL that was clicked by the recipient"
},
"user_agent": {
"type": "string",
"description": "For click and open events, the User-Agent header given when the recipient clicked a link or opened a message"
},
"smtp_id": {
"type": "string",
"description": "For click and open events, the Message-Id of the message that generated the event"
},
"ip": {
"type": "string",
"description": "For click and open events, the IP address of the host that made the HTTP request"
},
"recipients": {
"type": "array",
"description": "The recipients of the message"
}
},
"required": ["customer_handle", "timestamp", "event"],
"additionalProperties": false
},
"minItems": 1,
"maxItems": 1000
}