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
orbounced
(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
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 | Email received and being processed for delivery |
delivered | Email successfully delivered to recipient's server |
dropped | An error occurred when processing the email |
bounced | Email could not be delivered to recipient's server |
opened* | Recipient opened the email |
clicked* | Recipient clicked a link in the email |
complained* | Recipient filed a spam complaint about the email |
unsubscribed* | Recipient indicated they wish to unsubscribe from the email |
Note: If a dropped
event is generated, it means the API request also generated
an error. So if your event-processing code sees dropped
, you can safely assume that
the original API call to send the message also failed in your client code.
opened
and clicked
events require tracking to be enabled, which is not available in our free tier.
complained
and unsubscribed
are reserved for future use.
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 == "" || payload.RequestID == "" {
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
}
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 || !request_id) {
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'];
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 types complained
and unsubscribed
are 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"
},
"timestamp": {
"type": "integer",
"description": "The Unix timestamp (in seconds) when the event occurred; the timezone is always UTC"
},
"event": {
"type": "string",
"enum": ["processed", "delivered", "opened", "clicked", "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 bounced and dropped events, the SMTP status code that caused the bounce"
},
"reason": {
"type": "string",
"description": "For bounced and dropped events, a human-readable explanation of why the message 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"
}
},
"required": ["customer_handle", "timestamp", "event", "request_id"],
"additionalProperties": false
},
"minItems": 1,
"maxItems": 1000
}