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

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
processedEmail received and being processed for delivery
deliveredEmail successfully delivered to recipient's server
droppedAn error occurred when processing the email
bouncedEmail 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.

info

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

info

complained and unsubscribed are reserved for future use.

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

Appendix: JSON Schema

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

info

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
}