How to Implement Custom Bounce Handling with Amazon SES Webhooks in Listmonk

You can implement custom bounce handling in Listmonk by supplying a RecordBounceCB callback function when initializing the bounce manager, allowing you to execute custom logic—such as sending external notifications or updating subscriber statuses—after Amazon SES validates the webhook payload but before the bounce is persisted to the database.

Listmonk, the open-source newsletter and mailing list manager, provides built-in support for Amazon Simple Email Service (SES) bounce notifications through a robust webhook pipeline. Understanding how to implement custom bounce handling with SES webhooks enables you to extend Listmonk's default behavior to trigger external services, modify subscriber data, or implement custom alerting workflows. This guide walks through the internal flow and demonstrates exactly how to hook into the bounce processing pipeline using the callback system exposed in the source code.

Understanding the SES Bounce Flow in Listmonk

Listmonk processes SES bounce notifications through a structured pipeline that validates, transforms, and persists bounce data. The flow begins when Amazon SNS delivers a message to your instance.

The webhook endpoint POST /bounce/ses is registered in cmd/handlers.go. When a request arrives, the BounceWebhook handler in cmd/bounce.go reads the raw request and checks the X-Amz-Sns-Message-Type header to determine whether the message is a bounce notification or a subscription confirmation.

For bounce events, the handler delegates to webhooks.SES located in internal/bounce/webhooks/ses.go. This package handles two critical tasks:

  • ProcessSubscription validates the SNS signature and automatically follows the confirmation URL for SubscriptionConfirmation and UnsubscribeConfirmation messages.
  • ProcessBounce validates the SNS signature, unmarshals the SES bounce payload, and returns a models.Bounce object.

Once processed, the bounce.Manager in internal/bounce/bounce.go receives the bounce object. The manager maintains an internal queue processed by its Run loop. Before persisting data, it invokes core.RecordBounce from internal/core/bounces.go, which executes the SQL query to store the bounce and—crucially—triggers any custom callback you have configured via RecordBounceCB.

Enabling SES Webhook Support

Before implementing custom logic, you must configure Listmonk to receive SES notifications.

First, enable SES support in your configuration file. The SESEnabled flag is read into models.Settings from models/settings.go:121.

[bounce]
ses_enabled = true

Next, configure your AWS resources:

  1. Create an SNS topic in the AWS console.
  2. Subscribe the Listmonk endpoint (https://your-host/bounce/ses) to this topic.
  3. Configure your SES configuration set to send bounce events to the same SNS topic.

Listmonk automatically confirms the SNS subscription when it receives the first SubscriptionConfirmation message, handling the validation internally through the ProcessSubscription method.

Implementing Custom Bounce Handling

Listmonk exposes the RecordBounceCB callback hook in internal/bounce/bounce.go (see the New function at line 66). This allows you to intercept bounces after signature verification but before database persistence.

Using the RecordBounceCB Callback

To implement custom processing, define a callback function that matches the signature func(models.Bounce) error and pass it when constructing the bounce manager.

import (
    "log"

    "github.com/knadh/listmonk/internal/bounce"
    "github.com/knadh/listmonk/models"
)

// customCallback receives the bounce after validation but before storage.
func customCallback(b models.Bounce) error {
    // Example: Push to an external analytics API
    go func() {
        // Access b.Email, b.Type, b.CampaignUUID, etc.
        log.Printf("Processing bounce for %s, type: %s", b.Email, b.Type)
    }()
    
    // Return nil to allow Listmonk to continue with its DB insert.
    // Return an error to abort processing (the bounce won't be recorded).
    return nil
}

// During App initialization (typically in cmd/init.go or cmd/main.go):
opt := bounce.Opt{
    SESEnabled:     true,
    RecordBounceCB: customCallback,
    // ... other bounce options (mailbox, sendgrid, etc.)
}
mgr, err := bounce.New(opt, queries, logger)
if err != nil {
    log.Fatalf("bounce init: %v", err)
}
app.bounce = mgr

The callback is invoked in internal/bounce/bounce.go (line 28) within the manager's processing loop. If your callback returns an error, Listmonk aborts the bounce recording. If it returns nil, the standard database insert proceeds.

Alternative: Direct Queue Access

For advanced use cases requiring batch processing or custom queue consumption, you could technically run the manager's Run loop in your own goroutine and monitor the internal queue. However, the standard pattern remains using RecordBounceCB, as Listmonk already manages the Run loop lifecycle for you.

Complete Working Example: Slack Notifications

Here is a complete implementation that sends a Slack notification whenever Listmonk receives a bounce from SES.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net/http"

    "github.com/knadh/listmonk/internal/bounce"
    "github.com/knadh/listmonk/models"
)

func slackNotify(b models.Bounce) error {
    payload := map[string]string{
        "text": fmt.Sprintf("*Bounce Alert* %s (%s) in campaign %s",
            b.Email, b.Type, b.CampaignUUID),
    }
    
    body, _ := json.Marshal(payload)
    resp, err := http.Post(
        "https://hooks.slack.com/services/YOUR/WEBHOOK/URL",
        "application/json",
        bytes.NewReader(body),
    )
    
    if err != nil {
        return err
    }
    if resp.StatusCode != http.StatusOK {
        return fmt.Errorf("slack responded %d", resp.StatusCode)
    }
    
    // Return nil to allow Listmonk to store the bounce as usual.
    return nil
}

func main() {
    // ... initialize config, database, queries, logger ...
    
    opt := bounce.Opt{
        SESEnabled:     true,
        RecordBounceCB: slackNotify,
    }
    
    mgr, err := bounce.New(opt, queries, logger)
    if err != nil {
        panic(err)
    }
    
    go mgr.Run() // Start the background processor
    // ... continue with HTTP server setup ...
}

This example demonstrates how to access the models.Bounce struct fields—including Email, Type, CampaignUUID, and Source**—to enrich external notifications.

Key Source Files and Functions

Understanding these specific files helps you navigate the codebase for customization:

  • cmd/handlers.go: Registers the /bounce/:service route that receives webhook requests.
  • cmd/bounce.go: Contains BounceWebhook, the HTTP handler that routes SES messages to the appropriate webhook processor.
  • internal/bounce/webhooks/ses.go: Implements signature verification (verifyNotif), subscription confirmation (ProcessSubscription), and bounce extraction (ProcessBounce).
  • internal/bounce/bounce.go: Defines the Manager struct and the RecordBounceCB callback interface (see New function at line 66).
  • internal/core/bounces.go: Contains RecordBounce (line 59), which persists data and invokes the callback.

Summary

  • Listmonk handles SES bounces through a multi-stage pipeline: webhook reception in cmd/bounce.go, signature verification in internal/bounce/webhooks/ses.go, and persistence via internal/core/bounces.go.
  • Enable SES support by setting ses_enabled = true in the [bounce] section of config.toml.
  • Implement custom logic by providing a RecordBounceCB callback when initializing the bounce manager with bounce.New().
  • The callback receives a models.Bounce object after validation but before database storage, allowing you to abort processing or trigger external workflows.
  • Always return nil from your callback to allow standard Listmonk processing; return an error to prevent the bounce from being recorded.

Frequently Asked Questions

How does Listmonk verify the authenticity of SES webhook requests?

Listmonk validates SNS signatures through the verifyNotif function in internal/bounce/webhooks/ses.go (line 199). This cryptographic verification ensures that bounce notifications originate from Amazon SES before processing continues, protecting against spoofed webhook requests.

Can I use custom bounce handling alongside other providers like SendGrid?

Yes. The bounce.Opt struct accepts multiple provider configurations simultaneously (e.g., SESEnabled, SendgridEnabled, MailboxEnabled). You provide a single RecordBounceCB that receives all bounces regardless of source, allowing you to implement unified custom processing across Amazon SES, SendGrid, and mailbox-based bounce scanning.

What happens if my custom callback returns an error?

If your RecordBounceCB function returns a non-nil error, Listmonk aborts the bounce recording process. The bounce will not be inserted into the database, and the error will be logged. This allows you to implement validation logic that prevents certain bounces from being recorded, though you should handle errors carefully to avoid losing legitimate bounce data.

Do I need to manually confirm the SNS subscription?

No. Listmonk handles SNS subscription confirmations automatically through the ProcessSubscription method in internal/bounce/webhooks/ses.go. When AWS sends a SubscriptionConfirmation message to your /bounce/ses endpoint, Listmonk validates the signature and visits the confirmation URL for you, completing the subscription setup without manual intervention.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →