Skip to content

Conversation

@meysam81
Copy link
Contributor

@meysam81 meysam81 commented Dec 28, 2025

Hey @knadh

I studied the relevant issue (addressed below) and summed up the requirements into the following PR.

Summarize:

  • add support for 13 event types for subscribers, campaigns & bounces
  • all the events in the internal/core/ directory are properly triggering the correct events. if not webhook is defined by admin or if the no event type is matched, we ignore and move on.
  • for external webhooks, we support 3 auth type. none (default), basic auth with user-password, and hmac-sha256. choosing hmac-sha256 will set the computed X-Listmonk-Signature & X-Listmonk-Timestamp headers.
  • regardless of which auth is chosen, we set these headers: User-Agent: listmonk/v5.1.0, X-Listmonk-Event: subscriber.created
  • each webhook triggered will run the http request inside a goroutine to ensure maximum performance provided by the runtime

Demo

I'm providing the relevant screenshots here, but to avoid cluttering the description, putting it in html detail.

Event types Screenshot From 2025-12-28 21-08-20
Auth types Screenshot From 2025-12-28 21-08-35

JSON Payload

The following json payloads are taken from the echo-server

Subscriber created payload
{
  "name": "echo-server",
  "hostname": "aaf3a0b12c1f",
  "pid": 1,
  "level": 30,
  "host": {
    "hostname": "localhost",
    "ip": "::ffff:172.17.0.1",
    "ips": []
  },
  "http": {
    "method": "POST",
    "baseUrl": "",
    "originalUrl": "/listmonk",
    "protocol": "http"
  },
  "request": {
    "params": {},
    "query": {},
    "cookies": {},
    "body": {
      "event": "subscriber.created",
      "timestamp": "2025-12-28T13:54:35.544674992Z",
      "data": {
        "id": 11,
        "created_at": "2025-12-28T13:54:29.130854Z",
        "updated_at": "2025-12-28T13:54:29.130854Z",
        "uuid": "477f9364-8b0a-4e7b-b820-4b42bf2ff2d5",
        "email": "[email protected]",
        "name": "john",
        "attribs": {},
        "status": "enabled",
        "lists": [
          {
            "subscription_status": "unconfirmed",
            "subscription_created_at": "2025-12-28T13:54:29.130854+00:00",
            "subscription_updated_at": "2025-12-28T13:54:29.130854+00:00",
            "subscription_meta": {},
            "id": 2,
            "uuid": "3e95d710-3873-4873-af68-f4de58331ca9",
            "name": "Opt-in list",
            "type": "public",
            "optin": "double",
            "status": "active",
            "tags": [
              "test"
            ],
            "description": "",
            "created_at": "2025-12-27T04:18:58.360369+00:00",
            "updated_at": "2025-12-27T04:18:58.360369+00:00"
          }
        ]
      }
    },
    "headers": {
      "host": "localhost:10000",
      "user-agent": "listmonk-webhook/1.0",
      "content-length": "748",
      "content-type": "application/json",
      "x-listmonk-event": "subscriber.created",
      "x-listmonk-signature": "sha256=acc61cddfa8ec62ddadd58f40426791b6874e27428f981554435bdf7ebaaaed5",
      "x-listmonk-timestamp": "1766930075",
      "accept-encoding": "gzip"
    }
  },
  "msg": "Sun, 28 Dec 2025 13:54:35 GMT | [POST] - http://localhost:10000/listmonk",
  "time": "2025-12-28T13:54:35.554Z",
  "v": 0
}
Campaign started payload
{
  "name": "echo-server",
  "hostname": "aaf3a0b12c1f",
  "pid": 1,
  "level": 30,
  "host": {
    "hostname": "localhost",
    "ip": "::ffff:172.17.0.1",
    "ips": []
  },
  "http": {
    "method": "POST",
    "baseUrl": "",
    "originalUrl": "/listmonk",
    "protocol": "http"
  },
  "request": {
    "params": {},
    "query": {},
    "cookies": {},
    "body": {
      "event": "campaign.started",
      "timestamp": "2025-12-28T13:57:17.153960863Z",
      "data": {
        "id": 1,
        "created_at": "2025-12-27T04:18:59.092583Z",
        "updated_at": "2025-12-27T04:18:59.092583Z",
        "views": 0,
        "clicks": 0,
        "bounces": 0,
        "lists": [
          {
            "id": 1,
            "name": "Default list"
          }
        ],
        "media": [],
        "started_at": null,
        "to_send": 0,
        "sent": 0,
        "uuid": "4ab67b3f-78b1-45a5-8254-eea019c9e79e",
        "type": "regular",
        "name": "Test campaign",
        "subject": "Welcome to listmonk",
        "from_email": "No Reply <[email protected]>",
        "body": "<h3>Hi {{ .Subscriber.FirstName }}!</h3>\n\t\t<p>This is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.</p>\n\t\t<p>Here is a <a href=\"https://listmonk.app@TrackLink\">tracked link</a>.</p>\n\t\t<p>Use the link icon in the editor toolbar or when writing raw HTML or Markdown,\n\t\t\tsimply suffix @TrackLink to the end of a URL to turn it into a tracking link. Example:</p>\n\t\t<pre>&lt;a href=&quot;https:/&zwnj;/listmonk.app&#064;TrackLink&quot;&gt;&lt;/a&gt;</pre>\n\t\t<p>For help, refer to the <a href=\"https://listmonk.app/docs\">documentation</a>.</p>\n\t\t",
        "body_source": null,
        "altbody": null,
        "send_at": null,
        "status": "running",
        "content_type": "richtext",
        "tags": [
          "test-campaign"
        ],
        "headers": [],
        "template_id": 1,
        "messenger": "email",
        "archive": false,
        "archive_slug": "welcome-to-listmonk",
        "archive_template_id": 2,
        "archive_meta": {
          "name": "Subscriber"
        }
      }
    },
    "headers": {
      "host": "localhost:10000",
      "user-agent": "listmonk-webhook/1.0",
      "content-length": "1606",
      "content-type": "application/json",
      "x-listmonk-event": "campaign.started",
      "x-listmonk-signature": "sha256=020e28ab4718c9b99a01a1ef197e93f49c7ec1347162cfb9bc7858106fd310eb",
      "x-listmonk-timestamp": "1766930237",
      "accept-encoding": "gzip"
    }
  },
  "msg": "Sun, 28 Dec 2025 13:57:17 GMT | [POST] - http://localhost:10000/listmonk",
  "time": "2025-12-28T13:57:17.157Z",
  "v": 0
}

NOTE: if you still request additional changes, please let me know and I'll accommodate asap.

cheers 🥂

fixes #658

Future works

  • additional translations for i18n

@knadh
Copy link
Owner

knadh commented Dec 31, 2025

Thanks for the PR @meysam81.

  1. Triggering in core.go spawns a new goroutine on every call. The webhooks package uses goroutines to post webhook data on every call.
  • Out-of-order events. Since webhook posts happen concurrently, an update or delete of a subscriber can hit an endpoint before subscriber creation.
  • Spawning multiple goroutines on every event will cause unbounded resource usage. For instance, importing a CSV with 10k subscribers can generate 10k events in a second. A goroutine pool + a buffered channel should be used instead.
  1. HMAC is a bit of an overkill for a closed system like a mailing list manager. (GitLab, Mattermost, Metabase use tokens/BasicAuth for example)

  2. Robust webhooks aren't trivial. Imagine there's a CSV import with 20k subscribers. That'll generate 20k events in one shot, which have to be processed and sent out sequentially (at least sequentially per subscriber/entity ID). A buffered channel with N capacity will block, which is not acceptable. Dropping events on a buffered channel is also not acceptable because that renders the postback system untrustworthy.

  3. What if there's a restart of the app (setting changes cause restart)? All buffered webhook events are lost.

Most robust webhook implementations generally rely on some sort of persistence (Redis, Kafka, Sidekiq, RabbitMQ etc.). Bringing in a whole new external dependency isn't ideal at all, so here, again Postgres will have to be relied on for persistence. That brings in RDBMS performance considerations. Piled-up and persisted webhooks shouldn't cause the primary DB to choke and affect critical functionality in the app.

@meysam81
Copy link
Contributor Author

meysam81 commented Dec 31, 2025

@knadh

thanks for reviewing and your notes

I didn't get a clear message though. are you suggesting a re-architecture to make it more reliable and then merge it? or are you saying that we drop this altogether?

cause if it's the former...

I can suggest, from your notes, to make changes and perform the following:

Triggering in core.go spawns a new goroutine on every call

every trigger is a sync db persistent to webhook_log with status set to pending

then we'll have a single goroutine running with ticker (configurable) that will take and batch those webhooks and run them to completion

so, in essence we're dealing with only one goroutine overall

HMAC is a bit of an overkill

we can drop it but I don't believe it adds a lot of overhead. it's just a few added CPU cycles IMHO

Robust webhooks aren't trivial

What if there's a restart of the app

persisting webhooks to the db will survive restart based on the earlier recommendations


what do you think?

a few adjustments needed to deliver the reliability standard requested?

@knadh
Copy link
Owner

knadh commented Dec 31, 2025

are you suggesting a re-architecture to make it more reliable and then merge it? or are you saying that we drop this altogether?

Definitely not about dropping this. I am suggesting a discussion and analysis that'll lead to an ideal architecture.

we can drop it but I don't believe it adds a lot of overhead. it's just a few added CPU cycles IMHO

Not really about CPU cycles, but just product spec complexity. Best to ensure consistency across various interfaces (Messengers uses BasicAuth. We can also consider supporting an additional token auth, which is widely used, which can be in the future extended to Messengers. So [none, Token, BasicAuth]. )

Postgres will have to be relied on for persistence. That brings in RDBMS performance considerations. Piled-up and persisted webhooks shouldn't cause the primary DB to choke and affect critical functionality in the app.

I'm suggesting this as a possible path ahead, but this has to be evaluated/load tested before its finalized.

@meysam81
Copy link
Contributor Author

that's fair

I'll perform the changes discussed so far

do you have any recommendation on the benchmark tooling? any preference? or should I just search the web for what works the best or maybe write a custom client benchmark that runs (simulations) of different real world use-cases... for example importing a batch CSV as subscriber, many bounce reports by the upstream smtp server, etc.

any suggestion is welcome to close the gap

@meysam81
Copy link
Contributor Author

I looked at Svix webhook... those are the expert in webhook, its internal implementation and the quirks involved...

is it completely unacceptable to add another moving part, accepting svix apikey as env var and using their webhook-as-a-service?

https://docs.svix.com/

@knadh
Copy link
Owner

knadh commented Dec 31, 2025

Here's a Python snippet to generate dummy CSVs that you can import. Should easily trigger 10s of thousands of events. This is enough for load testing.

import csv
import random

f = open("/tmp/subs.csv", "w+")
w = csv.writer(f)
w.writerow(["name", "email", "attributes"])

for n in range(0, 20000):
	w.writerow([
		"First%d Last%d" % (n, n),
		"user%[email protected]" % (n,),
		"{\"age\": %d, \"city\": \"Test\"}" % (random.randint(20,70), random.randint(10,99),)
	])

An external SaaS dependency for webhooks, definitely not. It should be fully self-contained.

@meysam81
Copy link
Contributor Author

meysam81 commented Jan 1, 2026

@knadh

when an import of X number of subscriber happens from the admin panel, would you say you would want to receive one webhook for the entire batch? or one event per subscriber?

I think the former should be the case, ain't it?

@meysam81
Copy link
Contributor Author

meysam81 commented Jan 1, 2026

we now have a pool of webhook worker that can be configured on the admin panel

we also have 3 additional webhook event type for batch import

the only thing left from our conversations so far is to remove the HMAC and add token

everything else should be addressed I hope

@meysam81
Copy link
Contributor Author

meysam81 commented Jan 1, 2026

and that's it

the HMAC is removed

I think I've addressed your concerns in the new changes

please review it at your own pace 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

I am missing the webhook processing

2 participants