How to Implement Custom Bounce Handling with Postmark Webhooks in Listmonk

Listmonk ingests Postmark bounce notifications through the /api/bounce/postmark endpoint, authenticating requests via HTTP Basic Auth and mapping JSON payloads to internal bounce records using the Postmark.ProcessBounce method.

Listmonk is an open-source newsletter and mailing list manager that supports webhook-based bounce processing from multiple email service providers. To implement custom bounce handling with Postmark webhooks, you configure credentials in config.toml and point Postmark's webhook settings to Listmonk's bounce API endpoint.

Configuring Postmark Bounce Handling

Before Postmark can deliver bounce notifications, you must enable and secure the webhook endpoint in Listmonk's configuration. The settings reside in the [bounce] section of your config.toml file.

Set the following values:

[bounce]
postmark.enabled = true
postmark.username = "your-secure-username"
postmark.password = "your-secure-password"

When postmark.enabled is true, Listmonk initializes a Postmark struct via NewPostmark(username, password) defined in internal/bounce/webhooks/postmark.go. This struct wraps the authentication handler and bounce processing logic required to validate and ingest Postmark payloads.

Webhook Endpoint and Authentication Flow

Listmonk exposes a generic bounce webhook endpoint at /api/bounce/:service, where :service is the provider name. For Postmark, the full URL is:


https://YOUR_LISTMONK_HOST/api/bounce/postmark

In cmd/bounce.go, the BounceWebhook handler extracts the service parameter and reads the raw request body:

service := c.Param("service")
rawReq, err := io.ReadAll(c.Request().Body)

The Postmark implementation enforces security via HTTP Basic Auth. In internal/bounce/webhooks/postmark.go, lines 43-49 construct the middleware using constant-time credential comparison to prevent timing attacks:

authHandler: middleware.BasicAuth(makePostmarkAuthHandler(username, password))

When Postmark posts a bounce to your endpoint, Listmonk validates the username and password against your configuration using a secure constant-time comparison (lines 101-118) before processing the payload.

Processing Postmark Bounce Payloads

Once authenticated, Listmonk delegates to Postmark.ProcessBounce to transform the Postmark JSON into internal models.Bounce objects.

Payload Structure and Type Mapping

The method unmarshals the raw JSON into a postmarkNotif struct (lines 58-80). It then maps Postmark's bounce types—such as HardBounce, SoftBounce, SpamComplaint, and Unsubscribe—to Listmonk's internal BounceType* enum values.

Campaign Attribution

To link a bounce to a specific campaign, include the custom header X-Listmonk-Campaign when sending mail through Postmark. Listmonk extracts this UUID from the Metadata field of the Postmark payload (lines 85-90):

if campUUID, ok := n.Metadata["X-Listmonk-Campaign"]; ok {
    b.CampaignUUID = campUUID
}

Recording the Bounce

The function constructs a models.Bounce object (defined in models/bounces.go) and returns it to the generic handler in cmd/bounce.go. The handler then persists the record via a.bounce.Record(b) (lines 48-55), storing the bounce type, email address, timestamp, and raw metadata in the database.

Practical Implementation Examples

Example 1: Minimal Configuration

Enable Postmark bounce handling in your config.toml:

[bounce]
postmark.enabled = true
postmark.username = "listmonk-webhook-user"
postmark.password = "a-secure-random-password"

Example 2: Registering the Webhook with Postmark

Configure the webhook in your Postmark dashboard or via their API. The webhook must use HTTP Basic Auth with the credentials defined above:

curl -X POST https://api.postmarkapp.com/webhooks \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -H "X-Postmark-Server-Token: YOUR_SERVER_TOKEN" \
  -d '{
        "Url": "https://listmonk.example.com/api/bounce/postmark",
        "Triggers": ["Bounce", "SpamComplaint"],
        "BasicAuthUsername": "listmonk-webhook-user",
        "BasicAuthPassword": "a-secure-random-password"
      }'

Example 3: Sending Campaign-Tagged Email

When sending via Postmark's API, include the custom header to enable bounce attribution:

curl -X POST https://api.postmarkapp.com/email \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -H "X-Postmark-Server-Token: YOUR_SERVER_TOKEN" \
  -d '{
        "From": "newsletter@example.com",
        "To": "subscriber@example.com",
        "Subject": "Weekly Update",
        "HtmlBody": "<p>Hello!</p>",
        "Headers": [
          {
            "Name": "X-Listmonk-Campaign",
            "Value": "c1d2e3f4-5678-90ab-cdef-1234567890ab"
          }
        ]
      }'

Example 4: Testing the Webhook Endpoint

Simulate a Postmark bounce notification to verify your implementation:

curl -X POST https://listmonk.example.com/api/bounce/postmark \
  -u listmonk-webhook-user:a-secure-random-password \
  -H "Content-Type: application/json" \
  -d '{
        "RecordType": "Bounce",
        "MessageStream": "outbound",
        "ID": 12345,
        "Type": "HardBounce",
        "Email": "bounced@example.com",
        "BouncedAt": "2024-05-01T12:00:00Z",
        "Metadata": {
          "X-Listmonk-Campaign": "c1d2e3f4-5678-90ab-cdef-1234567890ab"
        }
      }'

A successful request returns:

{"status": true}

Example 5: Querying Stored Bounces

Retrieve processed bounces via the Listmonk API:

curl https://listmonk.example.com/api/bounces?source=postmark \
  -H "Authorization: Bearer YOUR_API_TOKEN"

The response includes bounce objects containing the email address, mapped bounce type, campaign UUID, and the raw Postmark payload stored in the meta field.

Summary

  • Endpoint: Listmonk receives Postmark bounces at /api/bounce/postmark as implemented in cmd/bounce.go.
  • Security: HTTP Basic Auth with constant-time credential comparison protects the endpoint (internal/bounce/webhooks/postmark.go, lines 101-118).
  • Configuration: Enable postmark.enabled in config.toml and supply unique username/password credentials.
  • Campaign Tracking: Add the X-Listmonk-Campaign custom header when sending email to associate bounces with specific campaigns.
  • Persistence: The Postmark.ProcessBounce method maps Postmark types to internal enums and stores records via a.bounce.Record.

Frequently Asked Questions

What bounce types does Listmonk support from Postmark?

Listmonk maps Postmark's HardBounce, SoftBounce, SpamComplaint, Unsubscribe, and other types to its internal bounce type enumeration defined in models/bounces.go. Hard bounces typically result in automatic subscriber suppression, while soft bounces are recorded for monitoring.

How do I secure the webhook endpoint?

Listmonk requires HTTP Basic Auth for all Postmark webhook requests. Configure a unique username and password in the [bounce] section of config.toml. The authentication handler uses constant-time comparison to prevent timing attacks, as seen in internal/bounce/webhooks/postmark.go lines 101-118. Never expose the endpoint without authentication or use default credentials.

Can I track which campaign caused a bounce?

Yes. When sending email through Postmark, include the X-Listmonk-Campaign header with your campaign UUID as the value. Listmonk extracts this from the Metadata field of the Postmark payload and stores it in the CampaignUUID field of the bounce record, allowing you to analyze bounce rates per campaign.

What happens if the webhook authentication fails?

If the username or password supplied by Postmark does not match your configuration, Listmonk returns an HTTP 401 Unauthorized error and aborts the request before processing the bounce payload. Failed authentication attempts are logged, and the bounce is not recorded in the database.

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 →