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

> Implement custom bounce handling for Amazon SES webhooks in Listmonk. Execute custom logic after webhook validation and before database persistence with a RecordBounceCB callback.

- Repository: [Kailash Nadh/listmonk](https://github.com/knadh/listmonk)
- Tags: how-to-guide
- Published: 2026-05-19

---

**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`](https://github.com/knadh/listmonk/blob/main/cmd/handlers.go)**. When a request arrives, the **`BounceWebhook`** handler in **[`cmd/bounce.go`](https://github.com/knadh/listmonk/blob/main/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`](https://github.com/knadh/listmonk/blob/main/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`](https://github.com/knadh/listmonk/blob/main/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`](https://github.com/knadh/listmonk/blob/main/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`**.

```toml
[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`](https://github.com/knadh/listmonk/blob/main/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.

```go
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`](https://github.com/knadh/listmonk/blob/main/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.

```go
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`](https://github.com/knadh/listmonk/blob/main/cmd/handlers.go)**: Registers the `/bounce/:service` route that receives webhook requests.
- **[`cmd/bounce.go`](https://github.com/knadh/listmonk/blob/main/cmd/bounce.go)**: Contains `BounceWebhook`, the HTTP handler that routes SES messages to the appropriate webhook processor.
- **[`internal/bounce/webhooks/ses.go`](https://github.com/knadh/listmonk/blob/main/internal/bounce/webhooks/ses.go)**: Implements signature verification (`verifyNotif`), subscription confirmation (`ProcessSubscription`), and bounce extraction (`ProcessBounce`).
- **[`internal/bounce/bounce.go`](https://github.com/knadh/listmonk/blob/main/internal/bounce/bounce.go)**: Defines the `Manager` struct and the `RecordBounceCB` callback interface (see `New` function at line 66).
- **[`internal/core/bounces.go`](https://github.com/knadh/listmonk/blob/main/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`](https://github.com/knadh/listmonk/blob/main/cmd/bounce.go)**, signature verification in **[`internal/bounce/webhooks/ses.go`](https://github.com/knadh/listmonk/blob/main/internal/bounce/webhooks/ses.go)**, and persistence via **[`internal/core/bounces.go`](https://github.com/knadh/listmonk/blob/main/internal/core/bounces.go)**.
- Enable SES support by setting `ses_enabled = true` in the `[bounce]` section of [`config.toml`](https://github.com/knadh/listmonk/blob/main/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`](https://github.com/knadh/listmonk/blob/main/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`](https://github.com/knadh/listmonk/blob/main/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.