# How to Implement Custom Bounce Handling with SendGrid Webhooks in Listmonk

> Learn to implement custom bounce handling with SendGrid webhooks in Listmonk. Securely ingest bounce events and automate list hygiene with verified payload signatures.

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

---

**Listmonk ingests SendGrid bounce events through an ECDSA-secured webhook endpoint that cryptographically verifies payload signatures before converting events into internal bounce records for automated list hygiene.**

The open-source newsletter platform Listmonk provides native support for **custom bounce handling with SendGrid webhooks**, enabling real-time processing of delivery failures without manual CSV imports. This integration validates webhook authenticity using public-key cryptography, then parses bounce classifications to distinguish between hard and soft bounces. The implementation spans configuration management, cryptographic verification, and HTTP endpoint handling across multiple packages in the `knadh/listmonk` repository.

## Configuration Settings

Bounce processing requires enabling the SendGrid provider and supplying an ECDSA public key for signature verification. These settings are defined in [[`models/settings.go`](https://github.com/knadh/listmonk/blob/main/models/settings.go)](https://github.com/knadh/listmonk/blob/master/models/settings.go):

- `SendgridEnabled` (`bool`): Toggles the webhook handler acceptance.
- `SendgridKey` (`string`): Base64-encoded ECDSA public key used to verify SendGrid webhook signatures.

When the application initializes, [[`internal/bounce/bounce.go`](https://github.com/knadh/listmonk/blob/main/internal/bounce/bounce.go)](https://github.com/knadh/listmonk/blob/master/internal/bounce/bounce.go#L90-L95) instantiates the SendGrid processor if enabled:

```go
sg, err := webhooks.NewSendgrid(opt.SendgridKey)

```

This occurs in the bounce service constructor, creating a singleton that persists for the application lifecycle.

## Signature Verification Architecture

The security model relies on cryptographic proof that events originate from SendGrid. The verification logic resides in [[`internal/bounce/webhooks/sendgrid.go`](https://github.com/knadh/listmonk/blob/main/internal/bounce/webhooks/sendgrid.go)](https://github.com/knadh/listmonk/blob/master/internal/bounce/webhooks/sendgrid.go) within the `verifyNotif` method:

```go
func (s *Sendgrid) verifyNotif(sig, timestamp string, b []byte) error {
    sigB, err := base64.StdEncoding.DecodeString(sig)
    if err != nil { return err }

    ecdsaSig := struct{ R, S *big.Int }{}
    if _, err := asn1.Unmarshal(sigB, &ecdsaSig); err != nil {
        return fmt.Errorf("error asn1 unmarshal of signature: %v", err)
    }

    h := sha256.New()
    h.Write([]byte(timestamp))
    h.Write(b)
    hash := h.Sum(nil)

    if !ecdsa.Verify(s.pubKey, hash, ecdsaSig.R, ecdsaSig.S) {
        return errors.New("invalid signature")
    }
    return nil
}

```

The function extracts the signature from the `X-SES-Signature` header and the timestamp from `X-SES-Timestamp`. It reconstructs the SHA-256 hash of the timestamp concatenated with the raw JSON body, then verifies this against the stored ECDSA public key using `ecdsa.Verify`. Invalid signatures result in immediate rejection with a 400 response, preventing spoofed bounce injections.

## Processing Bounce Events

Once verified, the `ProcessBounce` method unmarshals the JSON payload into `sendgridNotif` structs and converts them into Listmonk's internal `models.Bounce` format:

```go
func (s *Sendgrid) ProcessBounce(sig, timestamp string, b []byte) ([]models.Bounce, error) {
    if err := s.verifyNotif(sig, timestamp, b); err != nil {
        return nil, err
    }

    var notifs []sendgridNotif
    if err := json.Unmarshal(b, &notifs); err != nil {
        return nil, fmt.Errorf("error unmarshalling Sendgrid notification: %v", err)
    }

    out := make([]models.Bounce, 0, len(notifs))
    for _, n := range notifs {
        if n.Event != "bounce" { continue }

        typ := models.BounceTypeHard
        if n.BounceClassification == "technical" || n.BounceClassification == "content" {
            typ = models.BounceTypeSoft
        }

        bn := models.Bounce{
            CampaignUUID: n.CampaignUUID,
            Email:        strings.ToLower(n.Email),
            Type:         typ,
            Meta:         json.RawMessage(b),
            Source:       "sendgrid",
            CreatedAt:    time.Unix(n.Timestamp, 0),
        }
        out = append(out, bn)
    }
    return out, nil
}

```

Key processing behaviors include:

- **Event filtering**: Only payloads with `"event":"bounce"` are processed; other event types are ignored.
- **Classification mapping**: Bounces classified as `technical` or `content` are marked as soft bounces (`models.BounceTypeSoft`); all others default to hard bounces.
- **Metadata preservation**: The original JSON payload is stored in the `Meta` field for debugging and audit trails.
- **Campaign attribution**: Each bounce links to its originating campaign via `CampaignUUID`.

## HTTP Endpoint Integration

The webhook receiver is implemented in [[`cmd/bounce.go`](https://github.com/knadh/listmonk/blob/main/cmd/bounce.go)](https://github.com/knadh/listmonk/blob/master/cmd/bounce.go#L186-L193), which routes requests based on the `service` query parameter. When `service=sendgrid`, the handler extracts signature headers and delegates processing:

```go
case service == "sendgrid" && a.bounce.Sendgrid != nil:
    // Sendgrid sends multiple bounces.
    bs, err := a.bounce.Sendgrid.ProcessBounce(sig, ts, rawReq)
    if err != nil {
        return err
    }
    // ... store bs in database

```

The handler pulls `X-SES-Signature` into variable `sig` and `X-SES-Timestamp` into `ts`, passing the raw request body (`rawReq`) to the processor. Successfully processed bounces are persisted to the database and surfaced in the Listmonk dashboard.

## Implementation Steps

To activate custom bounce handling in your Listmonk instance:

1. **Enable the provider** in your settings (via UI or API):

```json
{
  "bounce": {
    "sendgrid_enabled": true,
    "sendgrid_key": "<BASE64_ECDSA_PUBLIC_KEY>"
  }
}

```

2. **Configure SendGrid Event Webhook**:
   - Set HTTP POST URL to `https://your-domain.com/bounce?service=sendgrid`
   - Enable signature verification and provide the same ECDSA public key used in Listmonk
   - Select "Bounce" events for delivery

3. **Verify connectivity** by testing with a sample payload:

```bash
curl -X POST "https://your-domain.com/bounce?service=sendgrid" \
  -H "X-SES-Signature: <base64_sig>" \
  -H "X-SES-Timestamp: <unix_timestamp>" \
  -H "Content-Type: application/json" \
  -d '[{"event":"bounce","email":"test@example.com","timestamp":1609459200,"campaign_uuid":"..."}]'

```

4. **Monitor bounces** via the Listmonk dashboard or API endpoint `/api/bounces`.

## Summary

- **Configuration**: Enable `SendgridEnabled` and provide the ECDSA public key in `SendgridKey` via [[`models/settings.go`](https://github.com/knadh/listmonk/blob/main/models/settings.go)](https://github.com/knadh/listmonk/blob/master/models/settings.go).
- **Security**: The [[`sendgrid.go`](https://github.com/knadh/listmonk/blob/main/sendgrid.go)](https://github.com/knadh/listmonk/blob/master/internal/bounce/webhooks/sendgrid.go) webhook handler verifies ECDSA signatures using `X-SES-Signature` and `X-SES-Timestamp` headers to prevent spoofing.
- **Processing**: The `ProcessBounce` function filters for bounce events, classifies them as hard or soft based on SendGrid's classification field, and stores the raw payload for auditing.
- **Endpoint**: The [[`cmd/bounce.go`](https://github.com/knadh/listmonk/blob/main/cmd/bounce.go)](https://github.com/knadh/listmonk/blob/master/cmd/bounce.go) handler routes SendGrid traffic when the `service` query parameter equals `sendgrid`.

## Frequently Asked Questions

### What cryptographic algorithm does Listmonk use to verify SendGrid webhooks?

Listmonk uses **ECDSA (Elliptic Curve Digital Signature Algorithm)** with SHA-256 hashing. The implementation in [[`internal/bounce/webhooks/sendgrid.go`](https://github.com/knadh/listmonk/blob/main/internal/bounce/webhooks/sendgrid.go)](https://github.com/knadh/listmonk/blob/master/internal/bounce/webhooks/sendgrid.go) decodes the ASN.1 signature structure and verifies it against the configured public key using Go's standard `crypto/ecdsa` package.

### How does Listmonk distinguish between hard and soft bounces from SendGrid?

The `ProcessBounce` function checks the `bounce_classification` field in the webhook payload. If the classification equals `"technical"` or `"content"`, the bounce is recorded as a **soft bounce** (`BounceTypeSoft`); otherwise, it defaults to a **hard bounce** (`BounceTypeHard`). This distinction determines whether an address should be suppressed immediately or retained for retry logic.

### Can I process other SendGrid events (opens, clicks) through the same endpoint?

No. The current implementation explicitly filters for `"event":"bounce"` and ignores other event types. If you need to capture additional engagement metrics, you would need to extend the `sendgridNotif` struct and modify the processing loop in [[`sendgrid.go`](https://github.com/knadh/listmonk/blob/main/sendgrid.go)](https://github.com/knadh/listmonk/blob/master/internal/bounce/webhooks/sendgrid.go) to handle those specific event types.

### Where are the processed bounce records stored?

Verified bounce notifications are converted to `models.Bounce` structs and persisted to Listmonk's PostgreSQL database. You can access these records through the **Bounces** dashboard section or via the REST API at `/api/bounces`, which returns JSON containing the email address, bounce type, source (`sendgrid`), campaign UUID, and the raw webhook metadata.