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/postmarkas implemented incmd/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.enabledinconfig.tomland supply unique username/password credentials. - Campaign Tracking: Add the
X-Listmonk-Campaigncustom header when sending email to associate bounces with specific campaigns. - Persistence: The
Postmark.ProcessBouncemethod maps Postmark types to internal enums and stores records viaa.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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →