I recently replaced my entire email marketing infrastructure with a self-hosted solution built around Listmonk, Amazon SES, n8n workflows, and a custom drip sequence controller. This post documents exactly how I built it, component by component, gotcha by gotcha, so you can apply the same patterns to your own projects.
Just want the business reasons behind this read my post at https://alanfuller.co.uk/blog/why-i-built-my-own-email-marketing-system/
I built this system with Claude as my coding partner over about three days of focused work. Having an AI that could hold context across the entire architecture while debugging individual components proved genuinely useful. I mention this not as an endorsement but because some of the design decisions emerged from that collaborative process, and that context matters if you’re trying to understand the reasoning.
This is a long post. I’m covering webhook verification, n8n workflow orchestration, Listmonk customisation, transactional email templates, drip sequence controllers, DevOps with Coolify, and how everything connects. If you’re building something similar, this should save you considerable time and frustration.
The Architecture at a Glance
Before diving into each component, here’s how the pieces fit together:
┌─────────────────────┐ ┌─────────────────────┐
│ Licensing Platform │ │ Free Plugin Optins │
│ (HMAC signed) │ │ (unsigned) │
└──────────┬──────────┘ └──────────┬──────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────┐
│ Webhook Verification Proxy (PHP) │
│ • HMAC verification for signed sources │
│ • Allowlist validation for unsigned sources │
│ • Adds X-Verified headers │
│ • Forwards to n8n │
└──────────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ n8n Workflow │
│ • Extract payload, normalise structure │
│ • Query Listmonk for existing subscriber │
│ • Merge attributes with priority logic │
│ • Set drip stage and next send time │
│ • Create or update subscriber │
│ • Handle race conditions │
└──────────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Listmonk │
│ • Subscriber storage with JSONB attributes │
│ • Campaign sending │
│ • Transactional email API │
│ • Amazon SES integration │
│ • SNS bounce/complaint handling │
└──────────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Drip Controller (PHP + Cron) │
│ • Runs every 15 minutes │
│ • Query subscribers with due drips │
│ • Send via Listmonk TX API │
│ • Advance drip stage, calculate next date │
└─────────────────────────────────────────────────┘
The entire stack runs on a single Hetzner VPS managed by Coolify. Total infrastructure cost is under fifteen pounds per month including SES sending fees at my current volume of around 12,000 subscribers.
DevOps with Coolify: The Foundation
Before diving into individual components, I want to talk about Coolify because it’s fundamental to how this all runs and because I think more developers should know about it.
Coolify is an open source, self-hostable platform that gives you Heroku-like deployment experiences on your own infrastructure. You point it at a VPS, and it handles Docker container orchestration, Traefik reverse proxy configuration, SSL certificates via Let’s Encrypt, environment variable management, and deployment pipelines. It’s essentially Platform-as-a-Service that you own completely.
I’ve been using Coolify for about a year now and it’s become my default deployment platform for anything that doesn’t need Kubernetes-level complexity. The experience is genuinely pleasant. You define your services in docker-compose files, connect a Git repository, and Coolify builds and deploys automatically on push. SSL certificates just work. Multiple services share the same host with automatic routing based on domain names. The web interface gives you logs, environment management, and deployment controls without needing to SSH into servers.
Where Coolify Shines
The biggest win is deployment friction reduction. Previously I’d spend hours configuring nginx virtual hosts, managing certificates with certbot, setting up systemd services, handling log rotation. Now I describe what I want in docker-compose and Coolify handles the rest.
The persistent volumes integration is solid. You define named volumes in your compose file and Coolify ensures they survive redeployments. For databases and file storage, this just works without thinking about it.
Environment variable management through the UI is convenient. I can have different values for staging versus production without managing multiple compose files. Secrets stay out of version control.
The build system handles multi-stage Docker builds, caching, and even building from Dockerfiles in subdirectories. For polyglot projects with multiple services, this flexibility matters.
Where Coolify Has Rough Edges
That said, Coolify has its quirks. The abstraction sometimes gets in the way when you need fine-grained control over Traefik configuration. If you need custom middleware, specific routing rules, or non-standard SSL handling, you’ll be fighting the interface rather than working with it.
Environment variable handling has some edge cases. Variables with special characters can behave unexpectedly. The inheritance model between project-level and service-level variables isn’t always intuitive.
Occasionally you encounter situations where the software you’re deploying wasn’t designed with Coolify’s patterns in mind. This brings me to my Listmonk contributions.
Why I Contributed PRs Instead of Forking
Listmonk bakes its templates into the compiled binary. The only way to override them was via a --static-dir command-line flag. There was no environment variable equivalent, which is how Coolify passes configuration to containers.
I had three options. First, maintain a custom Docker image with my templates baked in. This creates ongoing maintenance burden: every Listmonk update means rebuilding my image, testing compatibility, and deploying. Second, use a Coolify “command” override to pass the flag. This works but feels fragile and isn’t how other configuration is managed. Third, contribute a fix upstream that adds environment variable support for CLI flags.
I chose the third option. The change was small: modify the configuration loading code to convert underscored environment variable names to hyphenated CLI flags for top-level keys. Once merged, I could use the standard upstream Listmonk image and override templates via mounted volumes, configured entirely through environment variables.
The general principle: when working with Coolify or any deployment platform, prefer upstream images with configuration via environment variables over custom forks. Invest the time to contribute fixes upstream when possible. It pays dividends in reduced long-term maintenance. You benefit from security updates and new features without rebuilding custom images.
Part 1: Getting Listmonk Running on Coolify
Listmonk is an open-source, self-hosted newsletter and mailing list manager written in Go. It’s fast (handles millions of subscribers), has a clean interface, supports both campaigns and transactional emails, and has excellent templating with Go templates. Getting it running on Coolify required understanding a few specifics.
The Docker Compose Configuration
Listmonk needs PostgreSQL for its database. Here’s the docker-compose.yml structure I use:
version: "3.8"
services:
listmonk-db:
image: postgres:15-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER:-listmonk}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME:-listmonk}
volumes:
- listmonk-db:/var/lib/postgresql/data
networks:
- coolify
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-listmonk}"]
interval: 10s
timeout: 5s
retries: 5
listmonk:
image: listmonk/listmonk:latest
restart: unless-stopped
depends_on:
listmonk-db:
condition: service_healthy
environment:
TZ: Europe/London
LISTMONK_app__address: "0.0.0.0:9000"
LISTMONK_db__host: listmonk-db
LISTMONK_db__port: "5432"
LISTMONK_db__user: ${DB_USER:-listmonk}
LISTMONK_db__password: ${DB_PASSWORD}
LISTMONK_db__database: ${DB_NAME:-listmonk}
LISTMONK_db__ssl_mode: disable
LISTMONK_static_dir: /listmonk/uploads/static
volumes:
- listmonk-uploads:/listmonk/uploads
networks:
- coolify
volumes:
listmonk-db:
listmonk-uploads:
networks:
coolify:
external: true
Several things to note here. The healthcheck on PostgreSQL ensures Listmonk doesn’t try to connect before the database is ready. Without this, you get intermittent startup failures depending on which container initialises faster. The depends_on with condition: service_healthy makes the dependency explicit.
The external Coolify network is required for Traefik routing. When Coolify deploys, it attaches the network and generates the appropriate Traefik labels for domain routing and SSL termination.
The LISTMONK_static_dir environment variable points to where I’ll mount my template overrides. This is the fix from my PR: without it, you couldn’t set this via environment variables.
Database Initialisation
On first deployment, you need to run Listmonk’s installation to create database tables. You can do this by exec’ing into the container:
docker exec -it listmonk ./listmonk --install
Or configure Listmonk to auto-install by setting the appropriate flag. I prefer manual installation for the first run so I can verify everything is working.
The Static Directory and Template Customisation
With the LISTMONK_static_dir pointing to /listmonk/uploads/static, I can override specific templates without replacing the entire static directory. Listmonk checks the custom static directory first, then falls back to built-in templates for anything not overridden.
I wanted opt-in confirmation emails to show list descriptions alongside list names. The original template showed “Private list” for non-public lists, which tells users nothing useful. Here’s the directory structure:
/listmonk/uploads/static/
├── email-templates/
│ ├── subscriber-optin.html
│ └── subscriber-optin-campaign.html
└── public/
└── templates/
└── optin.html
The modified opt-in template snippet:
{{ range $i, $l := .Lists }}
<li>{{ .Name }}{{ if .Description }}: {{ .Description }}{{ end }}</li>
{{ end }}
To deploy these templates, I copy them into the persistent volume. You could also mount them as a bind mount from a config repository, but I prefer keeping templates in the uploads volume so they’re included in volume backups.
Configuring Amazon SES
Amazon SES provides enterprise-grade email delivery at remarkably low cost: $0.10 per 1,000 emails. At my volume, monthly sending costs are negligible compared to any hosted email marketing platform.
In Listmonk’s Settings under SMTP:
Host: email-smtp.eu-west-2.amazonaws.com
Port: 587
Auth Protocol: Login
Username: [SES SMTP username]
Password: [SES SMTP password]
TLS: STARTTLS
Max Connections: 10
Max Message Retries: 3
Idle Timeout: 15s
The SMTP credentials are different from your regular AWS access keys. To generate them, go to AWS IAM, create a new user (or use an existing one), attach the AmazonSESFullAccess policy. Then under Security credentials, create an access key and select “SMTP credentials” as the use case. AWS generates a unique username and password for SMTP.
Before SES lets you send to arbitrary addresses, you’re in “sandbox” mode where you can only send to verified addresses. To get production access, verify your sending domain with DKIM records (SES provides the DNS records to add), then request a sending limit increase. AWS typically approves within 24 hours if you provide a reasonable use case description. Be specific about your use case: newsletter to opt-in subscribers, transactional emails for a SaaS product, etc.
Setting Up SNS Bounce Handling
This is critical for maintaining sender reputation and often overlooked in self-hosted email setups. When emails bounce (address doesn’t exist, mailbox full, etc.) or recipients mark you as spam, you need to stop sending to those addresses immediately. ISPs track your bounce and complaint rates; too high and your emails start going to spam folders for everyone.
Listmonk has built-in support for Amazon SNS notifications. Here’s the setup process:
First, create an SNS topic in the AWS console. Go to SNS, create a new topic, name it something like email-bounces. Note the ARN.
Second, configure SES to publish to this topic. Go to SES, Configuration Sets, create a new configuration set. Add a destination of type SNS. Configure it to send Bounce and Complaint event types to your topic. Attach this configuration set to your sending domain or use it as the default.
Third, set up the SNS subscription. In SNS, create a subscription to your topic. Protocol is HTTPS, endpoint is your Listmonk URL plus /webhooks/service/ses. So https://email.example.com/webhooks/service/ses.
When you create the subscription, SNS sends a confirmation request to Listmonk. Listmonk automatically confirms it. You can verify by checking the subscription status in the SNS console; it should show “Confirmed”.
Now when emails bounce, the flow is: SES detects bounce → SES publishes to SNS topic → SNS sends webhook to Listmonk → Listmonk marks subscriber as blocklisted. Complaints work the same way. Your sender reputation stays healthy without manual intervention.
Fourth, in Listmonk’s Settings under Bounces, enable bounce processing and configure the source as Amazon SES/SNS.
Part 2: The Webhook Verification Service
I have two sources of subscriber events. My commercial licensing platform sends HMAC SHA256 signed webhooks for every user event: installations, trials, purchases, cancellations. My free plugin opt-in library (which is open source as part of my Free Plugin Lib) sends unsigned JSON payloads when users opt in.
n8n, my workflow automation tool, can do HMAC verification but it’s awkward. You need a Code node, the error handling is messy, and debugging signature mismatches is painful. I decided to build a dedicated verification proxy.
The Design
The verifier is a simple PHP service that accepts webhook POST requests, validates them based on source type, and forwards verified requests to n8n with authentication headers. The source determination is based on the plugin ID in the payload: signed sources are listed in one environment variable, unsigned sources in another.
For signed webhooks, verification uses HMAC SHA256. The raw request body is hashed with a secret key specific to that plugin, and the result is compared to the signature header. If they match, the request is authentic and unmodified.
For unsigned webhooks from my free plugin library, verification is simpler: the plugin ID must be in the allowlist, and the payload must contain a valid email address. This isn’t cryptographically secure, but the attack surface is limited: an attacker could subscribe arbitrary emails to my free plugin lists, which would just result in opt-in confirmation emails they’d have to click to confirm.
The Implementation Pattern
<?php
/**
* Webhook Verification Proxy
*
* Validates webhooks from multiple sources and forwards to n8n.
* Signed sources use HMAC SHA256 verification.
* Unsigned sources use allowlist plus basic validation.
*/
// Configuration from environment
$signed_sources = array_filter(explode(',', getenv('SIGNED_PLUGIN_IDS') ?: ''));
$unsigned_sources = array_filter(explode(',', getenv('UNSIGNED_PLUGIN_IDS') ?: ''));
$n8n_url = getenv('N8N_WEBHOOK_URL');
$n8n_secret = getenv('N8N_WEBHOOK_SECRET');
// Reject non-POST requests
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
// Get raw payload (needed for HMAC verification)
$payload = file_get_contents('php://input');
$data = json_decode($payload, true);
// Basic payload validation
if (!$data || !isset($data['plugin_id'])) {
http_response_code(400);
echo json_encode(['error' => 'Invalid payload structure']);
exit;
}
$source_id = (string)$data['plugin_id'];
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? null;
$verified_source = null;
// Route to appropriate verification strategy
if ($signature && in_array($source_id, $signed_sources)) {
// HMAC verification for signed sources
$secret_env = 'SECRET_' . $source_id;
$secret = getenv($secret_env);
if (!$secret) {
error_log("Webhook verifier: missing secret for source $source_id");
http_response_code(500);
echo json_encode(['error' => 'Configuration error']);
exit;
}
$calculated = hash_hmac('sha256', $payload, $secret);
// Timing-safe comparison prevents timing attacks
if (!hash_equals($calculated, $signature)) {
error_log("Webhook verifier: signature mismatch for source $source_id");
http_response_code(403);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
$verified_source = 'signed';
} elseif (in_array($source_id, $unsigned_sources)) {
// Allowlist validation for unsigned sources
$email = $data['objects']['user']['email'] ?? null;
// Email validation
if (!$email) {
http_response_code(400);
echo json_encode(['error' => 'Missing email']);
exit;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid email format']);
exit;
}
// RFC 5321 maximum email length
if (strlen($email) > 254) {
http_response_code(400);
echo json_encode(['error' => 'Email exceeds maximum length']);
exit;
}
$verified_source = 'unsigned';
} else {
error_log("Webhook verifier: unknown source $source_id");
http_response_code(403);
echo json_encode(['error' => 'Unknown source']);
exit;
}
// Forward to n8n with verification headers
$ch = curl_init($n8n_url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Webhook-Secret: ' . $n8n_secret,
'X-Webhook-Source: ' . $verified_source,
'X-Verified-Plugin: ' . $source_id,
'X-Verified: true',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
]);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curl_error = curl_error($ch);
curl_close($ch);
if ($curl_error) {
error_log("Webhook verifier: n8n forward failed: $curl_error");
http_response_code(502);
echo json_encode(['error' => 'Upstream error']);
exit;
}
// Pass through n8n's response
http_response_code($http_code);
echo $response;
Containerisation
I package this with nginx and PHP-FPM in a minimal Alpine container:
FROM php:8.3-fpm-alpine
# Install nginx and curl extension
RUN apk add --no-cache nginx curl \
&& docker-php-ext-install curl
# Copy configuration
COPY nginx.conf /etc/nginx/nginx.conf
COPY index.php /var/www/html/index.php
# Set permissions
RUN chown -R www-data:www-data /var/www/html
EXPOSE 80
# Run both PHP-FPM and nginx
CMD ["sh", "-c", "php-fpm & nginx -g 'daemon off;'"]
The nginx configuration routes all requests to the PHP script:
events {
worker_connections 128;
}
http {
access_log /dev/stdout;
error_log /dev/stderr;
server {
listen 80;
server_name _;
root /var/www/html;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}
}
Coolify Deployment Configuration
services:
webhook-verifier:
build: .
environment:
SIGNED_PLUGIN_IDS: "1001,1002,1003"
SECRET_1001: ${SECRET_1001}
SECRET_1002: ${SECRET_1002}
SECRET_1003: ${SECRET_1003}
UNSIGNED_PLUGIN_IDS: "free_a,free_b,free_c"
N8N_WEBHOOK_URL: https://workflow.example.com/webhook/subscriber-event
N8N_WEBHOOK_SECRET: ${N8N_WEBHOOK_SECRET}
networks:
- coolify
In Coolify, configure the domain (e.g., verify.example.com) and it will generate Traefik labels automatically. The secrets are stored in Coolify’s environment variable management, marked as sensitive so they don’t appear in logs.
Part 3: The Free Plugin Opt-in Library
My free WordPress plugins share a common library for various utilities including email opt-ins. This library is open source as part of my Free Plugin Lib project. When a user activates a free plugin and chooses to receive updates, the library sends a webhook to my verification service.
The Payload Structure Design Decision
A key design decision was structuring the payload to match my commercial licensing platform’s webhook format as closely as possible. This means my n8n workflow can process both sources with minimal conditional logic. The event type install.installed with is_premium: false tells the workflow this is a free user who just opted in.
$payload = [
'type' => 'install.installed',
'plugin_id' => $plugin_identifier,
'is_live' => true,
'created' => date('c'),
'objects' => [
'user' => [
'is_marketing_allowed' => true,
'email' => sanitize_email($email),
'first' => '',
'last' => '',
'ip' => $this->get_client_ip(),
'id' => null,
],
'install' => [
'is_premium' => false,
'is_active' => true,
'license_id' => null,
'trial_plan_id' => null,
'trial_ends' => null,
'country_code' => '',
'url' => get_site_url(),
]
]
];
The is_marketing_allowed flag indicates explicit opt-in consent. The is_premium: false in the install object indicates this is a free user. These map directly to the fields my commercial platform sends, so the n8n workflow treats them identically.
IP Address Capture
For GDPR compliance records, I capture the IP address at opt-in time. This requires checking various headers because the request might come through proxies:
private function get_client_ip() {
$headers = [
'HTTP_CF_CONNECTING_IP', // Cloudflare
'HTTP_X_FORWARDED_FOR', // Generic proxy
'HTTP_X_REAL_IP', // nginx proxy
'REMOTE_ADDR', // Direct connection
];
foreach ($headers as $header) {
if (!empty($_SERVER[$header])) {
// X-Forwarded-For can contain multiple IPs; take the first
$ips = explode(',', $_SERVER[$header]);
$ip = trim($ips[0]);
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
}
}
return '';
}
Part 4: The n8n Workflow
n8n is a workflow automation tool similar to Zapier but self-hostable. I use it to orchestrate the subscriber management logic. When a verified webhook arrives, n8n handles the complex logic of checking for existing subscribers, merging attributes intelligently, setting drip sequence states, and updating Listmonk.
Why n8n?
Several alternatives exist: Apache Airflow for complex DAGs, Temporal for durable workflows, custom code with a message queue. I chose n8n because:
Visual workflow editing makes debugging easier. When a webhook produces unexpected results, I can step through the workflow, see exactly what each node produced, and identify where logic went wrong.
The learning curve is gentle. I had a working workflow within hours, not days. The node-based approach maps well to “receive webhook, transform data, make API calls.”
Self-hosting is straightforward with Docker. It integrates well with Coolify’s deployment model.
The HTTP Request node handles the Listmonk API interactions without writing code.
That said, n8n has limitations. Complex conditional logic gets unwieldy visually. Performance at high volumes might be a concern (though I’m nowhere near that). The Code nodes for JavaScript are powerful but debugging is less convenient than a proper IDE.
Workflow Structure
The workflow has these stages:
Webhook Trigger
│
▼
Extract Payload (Code)
│
▼
Get Subscriber (HTTP)
│
▼
IF Subscriber Exists?
│
┌──┴───────────────────────────────────┐
│ │
▼ ▼
Merge Existing (Code) New Subscriber (Code)
│ │
▼ ▼
Update Subscriber (HTTP) Create Subscriber (HTTP)
│ │
▼ ├── Success ─────────────▶ Done
Done │
└── 409 Error ──▶ Get Subscriber
│
▼
Merge Existing
│
▼
Update Subscriber
│
▼
Done
Let me walk through each significant node.
Extract Payload Node
This JavaScript code normalises the incoming webhook into a consistent structure:
const body = $input.first().json.body;
const user = body.objects.user;
const install = body.objects.install || {};
const pluginId = String(body.plugin_id);
// Plugin configuration
// In production, this maps to actual list IDs in Listmonk
const PLUGINS = {
"1001": { name: "product-alpha", listId: 3 },
"1002": { name: "product-beta", listId: 4 },
"free_a": { name: "free-tool-a", listId: 10 },
"free_b": { name: "free-tool-b", listId: 11 },
// ... additional products
};
const plugin = PLUGINS[pluginId];
if (!plugin) {
throw new Error(`Unknown plugin: ${pluginId}`);
}
// Determine user status
let status = "free";
if (install.trial_plan_id && install.trial_ends) {
const trialEnds = new Date(install.trial_ends);
if (trialEnds > new Date()) {
status = "trial";
} else {
// Trial ended, check if they're premium
if (install.is_premium && install.license_id) {
status = "premium";
}
}
} else if (install.is_premium && install.license_id) {
status = "premium";
}
// Build plugin-specific attributes with prefix
const prefix = `p${pluginId}_`;
const pluginAttribs = {
[`${prefix}status`]: status,
[`${prefix}active`]: install.is_active ?? true,
[`${prefix}trial_ends`]: install.trial_ends ?? null,
[`${prefix}license_id`]: install.license_id ?? null,
[`${prefix}last_event`]: body.type,
[`${prefix}last_seen`]: body.created || new Date().toISOString()
};
return {
email: user.email.toLowerCase().trim(),
name: [user.first, user.last].filter(Boolean).join(' ') || null,
userAttribs: {
marketing_allowed: user.is_marketing_allowed === true,
user_id: user.id ?? null,
country: install.country_code || null,
ip: user.ip || null
},
pluginAttribs,
pluginId,
plugin,
status,
eventType: body.type,
trialEnds: install.trial_ends ?? null,
isRenewal: body.objects.payment?.is_renewal ?? false
};
The attribute prefixing pattern (p1001_status, p1002_drip_stage) allows a single subscriber record to track relationships with multiple products independently. This is crucial for my use case where one person might use several of my plugins.
Get Subscriber Node
This HTTP Request node queries Listmonk’s API to check if the subscriber already exists:
Method: GET
URL: https://email.example.com/api/subscribers
Authentication: Basic Auth
Username: api
Password: {{ $env.LISTMONK_API_KEY }}
Query Parameters:
query: subscribers.email = '{{ $json.email }}'
The response contains a data.results array. If it has entries, we have an existing subscriber to update.
Merge Existing Node
This is where the sophisticated attribute merging and drip sequence logic lives:
const existing = $('Get Subscriber').first().json.data.results[0];
const incoming = $('Extract Payload').first().json;
// Start with existing attributes, overlay new data
const mergedAttribs = {
...existing.attribs,
...incoming.userAttribs,
...incoming.pluginAttribs
};
// Drip sequence management
const dripStageKey = `p${incoming.pluginId}_drip_stage`;
const dripNextKey = `p${incoming.pluginId}_drip_next`;
const dripStartedKey = `p${incoming.pluginId}_drip_started`;
const currentDripStage = mergedAttribs[dripStageKey] || 'none';
const marketingAllowed = mergedAttribs.marketing_allowed === true;
/**
* Priority hierarchy prevents "downgrade" sequences.
* A free user who starts a trial shouldn't keep getting free emails.
* A trial user who purchases shouldn't keep getting trial emails.
*/
const getPriority = (stage) => {
if (!stage || stage === 'none' || stage === 'imported') return 0;
if (stage.startsWith('free_')) return 1;
if (stage.startsWith('trial_')) return 2;
if (stage.startsWith('premium_')) return 3;
if (stage === 'complete') return 99; // Never interrupt completed
return 0;
};
const currentPriority = getPriority(currentDripStage);
if (marketingAllowed) {
const now = new Date().toISOString();
let newStage = null;
let newPriority = 0;
// Determine what sequence should start based on event type
if (incoming.eventType === 'payment.created') {
/**
* GOTCHA: payment.created fires in two scenarios:
* 1. Trial starts with card capture (card taken, not charged)
* 2. Actual payment processes
*
* Distinguish by checking trial_ends date.
* If in future: this is a trial start, not a payment.
* If in past or null: this is an actual payment.
*/
const trialEnds = incoming.trialEnds;
const isRenewal = incoming.isRenewal;
const isActualPayment = !trialEnds || new Date(trialEnds) < new Date();
if (isActualPayment && !isRenewal) {
newStage = 'premium_1';
newPriority = 3;
}
// Trial start with card or renewal: no drip change
} else if (incoming.eventType === 'install.trial.started') {
newStage = 'trial_1';
newPriority = 2;
} else if (incoming.eventType === 'install.installed' && incoming.status === 'free') {
newStage = 'free_1';
newPriority = 1;
}
// Only start if new priority HIGHER than current
if (newStage && newPriority > currentPriority) {
mergedAttribs[dripStageKey] = newStage;
mergedAttribs[dripNextKey] = now; // Eligible immediately
mergedAttribs[dripStartedKey] = now;
}
}
// Merge list memberships (add to plugin's list if not already member)
const existingListIds = existing.lists.map(l => l.id);
const newListId = incoming.plugin.listId;
const lists = existingListIds.includes(newListId)
? existingListIds
: [...existingListIds, newListId];
return {
subscriberId: existing.id,
email: existing.email,
name: incoming.name || existing.name,
attribs: mergedAttribs,
lists,
status: "enabled"
};
New Subscriber Node
For subscribers who don’t exist yet, we prepare initial attributes:
const incoming = $('Extract Payload').first().json;
const now = new Date().toISOString();
const attribs = {
...incoming.userAttribs,
...incoming.pluginAttribs
};
const marketingAllowed = attribs.marketing_allowed === true;
// Set initial drip sequence
if (marketingAllowed) {
const dripStageKey = `p${incoming.pluginId}_drip_stage`;
const dripNextKey = `p${incoming.pluginId}_drip_next`;
const dripStartedKey = `p${incoming.pluginId}_drip_started`;
let initialStage = 'none';
if (incoming.eventType === 'payment.created') {
const trialEnds = incoming.trialEnds;
const isRenewal = incoming.isRenewal;
const isActualPayment = !trialEnds || new Date(trialEnds) < new Date();
if (isActualPayment && !isRenewal) {
initialStage = 'premium_1';
}
} else if (incoming.eventType === 'install.trial.started') {
initialStage = 'trial_1';
} else if (incoming.eventType === 'install.installed' && incoming.status === 'free') {
initialStage = 'free_1';
}
if (initialStage !== 'none') {
attribs[dripStageKey] = initialStage;
attribs[dripNextKey] = now;
attribs[dripStartedKey] = now;
}
}
return {
email: incoming.email,
name: incoming.name,
attribs,
lists: [incoming.plugin.listId],
status: "enabled"
};
Handling Race Conditions
Two webhooks can arrive nearly simultaneously for the same email address. Both execute the Get Subscriber query and find nothing. Both proceed to Create Subscriber. The first succeeds. The second fails with HTTP 409 Conflict because Listmonk enforces email uniqueness.
Without handling, this causes workflow failures and data loss. The fix is to treat 409 as a recoverable condition, not an error.
On the Create Subscriber HTTP node, go to Settings, On Error, and select “Continue (Using Error Output)”. This means the node will produce output even on failure, including error details.
Add an IF node after Create Subscriber that checks {{ $json.errorDetails.httpCode }} equals "409". (Note: it’s a string, not a number.)
If true, route to a new branch: Get Subscriber → Merge Existing → Update Subscriber. This retrieves the just-created record (from the racing webhook) and merges our data into it.
The final workflow handles both paths gracefully.
Part 5: Transactional Email Templates
Listmonk distinguishes between campaign templates (wrappers with a content placeholder) and transactional templates (complete standalone emails). Drip sequence emails need to be transactional templates because they’re sent individually via the TX API.
The Unsubscribe Challenge
Transactional templates in Listmonk don’t have access to {{ .Subscriber.UnsubscribeURL }}. This makes sense for password reset emails or order confirmations, but drip sequences are marketing emails and legally require an unsubscribe link under GDPR and CAN-SPAM.
I discovered Listmonk’s unsubscribe URL follows a predictable format:
https://listmonk.example.com/subscription/{campaign-uuid}/{subscriber-uuid}
For transactional emails without an associated campaign, you can use a null UUID for the campaign portion:
<a href="https://email.example.com/subscription/00000000-0000-0000-0000-000000000000/{{ .Subscriber.UUID }}">
Unsubscribe
</a>
This works because Listmonk’s unsubscribe handler validates the subscriber UUID but accepts any campaign UUID including the null one. I was pleasantly surprised this wasn’t rejected as invalid.
Template Structure
Here’s the general structure I use for drip emails:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
background-color: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI',
Roboto, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 1.6;
margin: 0;
padding: 0;
color: #333;
}
.container {
max-width: 580px;
margin: 40px auto;
background: #ffffff;
border-radius: 8px;
overflow: hidden;
}
.content {
padding: 40px;
}
.footer {
background: #f9f9f9;
padding: 20px 40px;
font-size: 13px;
color: #666;
border-top: 1px solid #eee;
}
.footer a {
color: #666;
}
a {
color: #0066cc;
}
</style>
</head>
<body>
<div class="container">
<div class="content">
<p>Hi {{ .Subscriber.Name | default "there" }},</p>
<!-- Email content here -->
<p>Best regards,<br>Your Name</p>
</div>
<div class="footer">
<p>You're receiving this because you opted in when using [Product Name].</p>
<p>
<a href="https://email.example.com/subscription/00000000-0000-0000-0000-000000000000/{{ .Subscriber.UUID }}">
Unsubscribe
</a>
</p>
</div>
</div>
</body>
</html>
Testing Transactional Sends
Before connecting the drip controller, test templates manually:
curl -u "api:YOUR_API_KEY" "https://email.example.com/api/tx" \
-X POST \
-H 'Content-Type: application/json' \
-d '{
"subscriber_email": "your-test@example.com",
"template_id": 6,
"data": {},
"content_type": "html"
}'
Note: Listmonk’s API uses Basic authentication, not Bearer tokens. The username is literally api and the password is your API key from Listmonk’s Settings > API.
Part 6: The Drip Controller
Listmonk doesn’t have built-in drip sequence functionality. You can schedule campaigns to send at specific times, but you can’t define “send email 2 three days after email 1, per subscriber.” I built a PHP service running on cron to handle this.
Why Not n8n Wait Nodes?
I considered building drip functionality into n8n using Wait nodes. A Wait node pauses workflow execution for a specified duration. You could wait 3 days, then continue to send the next email.
This has fundamental problems. Wait nodes hold execution state in memory. If n8n restarts (for updates, crashes, or redeployment), all waiting workflows are lost. For a drip sequence that might wait days between emails, this is unacceptable. You’d have subscribers who never receive their complete sequence because n8n restarted.
The solution is storing state externally. The drip controller is stateless: it queries Listmonk for subscribers who are due, sends their emails, advances their state in Listmonk, and exits. If the controller crashes or the server reboots, nothing is lost because the state lives in Listmonk’s PostgreSQL database.
The State Model
Each subscriber’s drip state is stored in their Listmonk attributes:
{
"marketing_allowed": true,
"p1001_status": "free",
"p1001_drip_stage": "free_2",
"p1001_drip_next": "2025-12-10T09:00:00Z",
"p1001_drip_started": "2025-12-07T14:30:00Z",
"p1002_status": "premium",
"p1002_drip_stage": "complete",
"p1002_drip_next": null
}
The drip_stage indicates their position in the sequence. The drip_next is when they’re eligible for the next email. The controller queries for subscribers where drip_next is in the past.
Sequence Configuration
Sequences are defined in a configuration file:
<?php
// config/sequences.php
return [
// Product A: multi-email sequences
'1001' => [
'free' => [
['stage' => 'free_1', 'template_id' => 10, 'delay_days' => 0],
['stage' => 'free_2', 'template_id' => 11, 'delay_days' => 3],
['stage' => 'free_3', 'template_id' => 12, 'delay_days' => 7],
['stage' => 'free_4', 'template_id' => 13, 'delay_days' => 14],
],
'trial' => [
['stage' => 'trial_1', 'template_id' => 20, 'delay_days' => 0],
['stage' => 'trial_2', 'template_id' => 21, 'delay_days' => 2],
['stage' => 'trial_3', 'template_id' => 22, 'delay_days' => 5],
],
'premium' => [
['stage' => 'premium_1', 'template_id' => 30, 'delay_days' => 0],
],
],
// Free products: simple single-email welcome
'free_a' => [
'free' => [
['stage' => 'free_1', 'template_id' => 40, 'delay_days' => 0],
],
],
'free_b' => [
'free' => [
['stage' => 'free_1', 'template_id' => 41, 'delay_days' => 0],
],
],
];
The delay_days is how long to wait before the NEXT email. When free_1 is sent with delay_days => 3, the subscriber’s drip_next is set to 3 days in the future, making them eligible for free_2 then.
The Core Processing Logic
<?php
// drip-runner.php
require_once __DIR__ . '/src/ListmonkClient.php';
// Prevent concurrent execution with file locking
$lockFile = '/tmp/drip-controller.lock';
$fp = fopen($lockFile, 'c');
if (!flock($fp, LOCK_EX | LOCK_NB)) {
exit(0); // Another instance running
}
$client = new ListmonkClient(
getenv('LISTMONK_URL'),
getenv('LISTMONK_USER'),
getenv('LISTMONK_PASS')
);
$sequences = require __DIR__ . '/config/sequences.php';
$pluginIds = array_keys($sequences);
// Build SQL conditions for due drips across all products
$conditions = [];
foreach ($pluginIds as $pid) {
$safeId = preg_replace('/[^a-zA-Z0-9_]/', '', $pid);
$conditions[] = sprintf(
"(subscribers.attribs->>'p%s_drip_next' IS NOT NULL " .
"AND subscribers.attribs->>'p%s_drip_next' <= '%s')",
$safeId, $safeId, date('c')
);
}
$query = "subscribers.attribs->>'marketing_allowed' = 'true' " .
"AND subscribers.status = 'enabled' " .
"AND (" . implode(' OR ', $conditions) . ")";
// Process subscribers in batches
$page = 1;
while (true) {
$subscribers = $client->querySubscribers($query, $page, 100);
if (empty($subscribers)) break;
foreach ($subscribers as $sub) {
processSubscriber($sub, $sequences, $pluginIds, $client);
}
$page++;
}
flock($fp, LOCK_UN);
fclose($fp);
function processSubscriber($sub, $sequences, $pluginIds, $client) {
foreach ($pluginIds as $pluginId) {
$stageKey = "p{$pluginId}_drip_stage";
$nextKey = "p{$pluginId}_drip_next";
$stage = $sub['attribs'][$stageKey] ?? null;
$next = $sub['attribs'][$nextKey] ?? null;
// Skip if not due
if (!$stage || !$next || strtotime($next) > time()) {
continue;
}
// Skip completed or imported
if ($stage === 'complete' || $stage === 'imported') {
continue;
}
// Find current step in sequence
$step = findStep($sequences, $pluginId, $stage);
if (!$step) {
error_log("No step config for stage: $stage");
continue;
}
// Send email
$success = $client->sendTx($sub['email'], $step['template_id']);
if (!$success) continue;
// Advance to next step
$nextStep = findNextStep($sequences, $pluginId, $stage);
$newAttribs = $sub['attribs'];
if ($nextStep) {
$newAttribs[$stageKey] = $nextStep['stage'];
$delaySeconds = $nextStep['delay_days'] * 86400;
$newAttribs[$nextKey] = date('c', time() + $delaySeconds);
} else {
$newAttribs[$stageKey] = 'complete';
$newAttribs[$nextKey] = null;
}
$client->updateSubscriber($sub['id'], [
'email' => $sub['email'],
'name' => $sub['name'],
'status' => $sub['status'],
'lists' => array_map(fn($l) => $l['id'], $sub['lists']),
'attribs' => $newAttribs,
]);
}
}
function findStep($sequences, $pluginId, $stage) {
if (!isset($sequences[$pluginId])) return null;
foreach ($sequences[$pluginId] as $steps) {
foreach ($steps as $step) {
if ($step['stage'] === $stage) return $step;
}
}
return null;
}
function findNextStep($sequences, $pluginId, $currentStage) {
if (!isset($sequences[$pluginId])) return null;
foreach ($sequences[$pluginId] as $steps) {
for ($i = 0; $i < count($steps); $i++) {
if ($steps[$i]['stage'] === $currentStage) {
return $steps[$i + 1] ?? null;
}
}
}
return null;
}
Docker Setup
FROM php:8.3-cli-alpine
RUN apk add --no-cache dcron curl-dev \
&& docker-php-ext-install curl
WORKDIR /app
COPY . /app/
RUN mkdir -p /var/log
# Run every 15 minutes
RUN echo "*/15 * * * * cd /app && php drip-runner.php >> /var/log/drip.log 2>&1" \
> /etc/crontabs/root
CMD ["crond", "-f", "-l", "2"]
Handling Imported Subscribers
When I migrated 12,000 subscribers from my previous platform, I didn’t want them all receiving “Welcome!” emails. The solution: set their drip_stage to imported during import.
The n8n workflow only starts sequences when the current stage is none, or when starting a higher-priority sequence (trial interrupts free, premium interrupts trial). Imported subscribers stay at imported until they take an action that triggers a sequence, like starting a trial or making a purchase.
Part 7: Campaign List Building
Listmonk’s campaign targeting interface lets you select lists and use basic conditions, but it can’t filter by arbitrary subscriber attributes. I frequently need to target segments like “free users of product A who completed their drip sequence more than 30 days ago and haven’t opened recent campaigns.”
I built a simple PHP application that queries Listmonk’s PostgreSQL database directly using JSONB operators, presents a visual query builder, and creates campaigns targeting specific subscriber sets.
This turned out to be one of the most useful tools in the system. I can build complex queries that would be impossible in Listmonk’s native interface, preview the subscriber count and sample emails, then launch a campaign targeting exactly that audience.
If there’s demand, I’d consider open sourcing both the campaign builder and the drip controller. Let me know if that would be useful.
Summary of Gotchas
Throughout this build, I encountered numerous issues that cost debugging time. Here’s a consolidated list:
Listmonk static directory requires code change. The --static-dir flag had no environment variable equivalent until my PR was merged. When deploying with Coolify or similar platforms, prefer contributing upstream fixes over maintaining forks.
Coolify domain routing parses strictly. Traefik label formatting must be exact. Issues cause silent failures or broken routing rules with empty Host() matchers.
n8n HMAC verification is awkward. Possible with Code nodes but error handling is messy. A dedicated verification proxy is cleaner and more maintainable.
TX templates lack unsubscribe URLs. Use the null campaign UUID workaround: 00000000-0000-0000-0000-000000000000.
Listmonk API uses Basic auth. Not Bearer tokens. Username is api, password is your API key.
payment.created fires twice for trials. Once for trial start with card capture, once for actual payment. Check trial_ends date AND is_renewal flag to distinguish.
Race conditions in webhook processing. Two simultaneous webhooks for the same email can race. Handle 409 errors gracefully by falling back to merge/update logic.
Imported subscribers need special handling. Set drip_stage to imported to prevent inappropriate welcome sequences on legacy data.
PostgreSQL JSONB comparisons are string-based. The attribs->>'key' syntax returns text. Date comparisons work because ISO 8601 format sorts lexicographically, but be aware when debugging.
n8n Wait nodes lose state on restart. Don’t use them for anything spanning more than minutes. Store state externally and use polling patterns instead.
Conclusion
Building this system took about three days of focused work with Claude as my coding partner. The result handles webhooks from multiple sources securely, maintains rich per-product subscriber attributes, runs intelligent drip sequences with priority-based interrupts, and costs under a hundred fifty pounds per year instead of nearly two thousand.
The capability gains justify the effort more than the cost savings. I can now segment precisely by product and status, automatically interrupt sequences when users upgrade, maintain unified multi-product subscriber records, and control every aspect of the system.
Coolify made the DevOps side manageable. Being able to deploy multiple interconnected services with automatic SSL and routing, without managing Kubernetes complexity, let me focus on application logic rather than infrastructure. The investment in contributing upstream fixes to Listmonk means I use standard images without fork maintenance burden.
Would I recommend this approach? If you have multiple products with overlapping user bases, complex event-driven communication needs, and the technical capability to build and maintain it, then yes. If you’re sending monthly newsletters to a simple list, stick with hosted platforms.
Questions or want to discuss the implementation? Find me at alanfuller.co.uk

Leave a Reply