How to Implement Custom Bounce Handling with SendGrid Webhooks in Listmonk
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/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/master/internal/bounce/bounce.go#L90-L95) instantiates the SendGrid processor if enabled:
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/master/internal/bounce/webhooks/sendgrid.go) within the verifyNotif method:
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:
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, ¬ifs); 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
technicalorcontentare marked as soft bounces (models.BounceTypeSoft); all others default to hard bounces. - Metadata preservation: The original JSON payload is stored in the
Metafield 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/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:
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:
- Enable the provider in your settings (via UI or API):
{
"bounce": {
"sendgrid_enabled": true,
"sendgrid_key": "<BASE64_ECDSA_PUBLIC_KEY>"
}
}
-
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
- Set HTTP POST URL to
-
Verify connectivity by testing with a sample payload:
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":"..."}]'
- Monitor bounces via the Listmonk dashboard or API endpoint
/api/bounces.
Summary
- Configuration: Enable
SendgridEnabledand provide the ECDSA public key inSendgridKeyvia [models/settings.go](https://github.com/knadh/listmonk/blob/master/models/settings.go). - Security: The [
sendgrid.go](https://github.com/knadh/listmonk/blob/master/internal/bounce/webhooks/sendgrid.go) webhook handler verifies ECDSA signatures usingX-SES-SignatureandX-SES-Timestampheaders to prevent spoofing. - Processing: The
ProcessBouncefunction 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/master/cmd/bounce.go) handler routes SendGrid traffic when theservicequery parameter equalssendgrid.
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/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/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.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →