Skip to main content

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:

  1. POST: Create a webhook to receive delivery events
  2. GET: Retrieve an existing webhook configuration
  3. DELETE: 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 address
  • customer_handle: the account ID that you can find in the upper-right corner of the MailChannels Console
  • timestamp: Unix timestamp of the event
  • event: Event type, such as delivered or hard-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 is exampleuser then the customer_handle field will be set to exampleuser for webhooks related to emails sent by that MailChannels account.

tip

To improve your webhook security, we highly recommend verifying that the customer_handle value matches your account ID.

info

To find your account ID, go to the upper-right corner of the MailChannels Console

info

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 TypeDescription
processedSystem received and began processing email for delivery
deliveredRecipient's server accepted the email
droppedSystem encountered an error when processing the email
unsubscribedRecipient 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
info

open and click events require tracking to be enabled, which is not available in our free tier.

info

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 the Message-Id header. This will be blank if the message bounces before headers are processed.

Best Practices

  1. Ensure request integrity by verifying the message signature.
  2. Ensure your webhook endpoint can handle concurrent requests.
  3. Authenticate our requests by checking that each request contains your account ID in the customer_handle field.
  4. Process events asynchronously to avoid blocking the webhook receiver.
  5. Implement retry logic in case of temporary failures.
  6. 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

  1. 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 name sig_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 was ed25519
    ⅳ. The ID of the key used to sign the request is mckey
  1. Check the created timestamp. If too much time has passed, consider rejecting the message in order to prevent replay attacks.

  2. 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
  3. 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.
  1. 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

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

Appendix: JSON Schema

The following JSON Schema describes the data structure that will be sent to your webhook by Email API.

info

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
}