listmonk is a flat module with global auth state, not a client object. Configure the base URL, log in once, then call functions directly. set_url_base() must come before everything, and login() returns a bool (it does not raise on bad credentials) — check it.
import listmonk
listmonk.set_url_base('https://listmonk.yourdomain.com') # scheme required; no /api path
if not listmonk.login('admin', 'super-secret'): # False = rejected OR unreachable
raise SystemExit('Login failed: check credentials and base URL.')
# Add someone, then send them a transactional email.
sub = listmonk.create_subscriber('user@example.com', 'Jane Doe', list_ids={1}, pre_confirm=True)
listmonk.send_transactional_email('user@example.com', template_id=3, template_data={'name': 'Jane'})Because auth is module-level global state, only one Listmonk instance can be targeted at a time and credential changes are not thread-safe. Every data call runs an internal state check and raises OperationNotAllowedError if the base URL is unset or you have not logged in.
This trips people up. Some functions raise on failure; others report failure through their return value. Do not wrap the "returns False" ones in try/except expecting an exception.
- Return
False(never raise on failure):login(),is_healthy(),verify_login()(rejected creds / unreachable),confirm_optin()(non-2xx status),add_subscribers_to_lists()(empty inputs or error status). - Return
Nonewhen nothing matches:subscriber_by_email(),subscriber_by_id(),subscriber_by_uuid(),campaign_by_id(),template_by_id(), andset_campaign_status()/start_campaign()/pause_campaign()/cancel_campaign()for an unknown campaign ID.test_campaign()returnsFalsein that same case. - Raise: most create/update/delete calls raise
ValueErrorfor bad arguments,httpx2.HTTPStatusErroron 4xx/5xx, andValidationErroron an empty/malformed server body.list_by_id()returns aMailingList(notOptional) and raises if the ID is missing.
Note httpx2 (a fork of httpx with a near-identical API), not httpx: catch httpx2.HTTPStatusError and build timeouts with httpx2.Timeout(timeout=30.0).
update_subscriber, update_campaign, and update_template take the model object, not loose fields. Fetch it, mutate attributes in place, pass it back — the client sends the full record and re-fetches the server's fresh copy as the return value.
sub = listmonk.subscriber_by_email('user@example.com')
sub.name = 'Updated Name'
sub.attribs['rating'] = 7
# List membership: existing lists - remove_from_lists + add_to_lists
updated = listmonk.update_subscriber(sub, add_to_lists={4}, remove_from_lists={5})update_subscriber(status=...) can enable/disable/block, but for status-only changes prefer the dedicated wrappers: enable_subscriber(sub), disable_subscriber(sub), block_subscriber(sub). Use block_subscriber to unsubscribe someone while keeping their record; use delete_subscriber(email) to erase them entirely.
subscribers(query_text=...) passes a server-side SQL-like filter over the subscribers table. Custom attributes are queried through the JSONB ->> operator. This requires the subscribers:sql_query permission on the user's role or the server returns 403.
listmonk.subscribers(query_text="subscribers.email = 'user@example.com'")
listmonk.subscribers(query_text="subscribers.attribs->>'city' = 'Portland'")
listmonk.subscribers(list_id=3) # list filter needs no permissionListmonk renders templates with Go's text/template/html/template, so the syntax is {{ ... }} with a leading dot for context — not Jinja/Django. Every template body must contain the placeholder {{ template "content" . }} exactly once, or create_template() raises ValueError before any request is sent.
# Campaign body pulls subscriber fields from .Subscriber
body = '<html><body>Hi {{ .Subscriber.FirstName }}! {{ template "content" . }}</body></html>'
listmonk.create_template(name='Welcome', body=body, type='campaign')There are two template types: 'campaign' and 'tx' (transactional). Merge data you pass to send_transactional_email(template_data=...) is available in a tx template as {{ .Tx.Data.<key> }}; subscriber fields are {{ .Subscriber.<Field> }}.
Attaching a file to a campaign is a two-step flow: upload to the media library, then reference the returned id. Attaching to a transactional email is inline via Path objects — no upload step.
from pathlib import Path
# Campaign attachment: upload_media() -> media_ids
media = listmonk.upload_media(Path('/path/to/report.png')) # or bytes + filename=
listmonk.create_campaign(name='Report', subject='This month', media_ids=[media.id])
# Transactional attachment: pass Paths directly
listmonk.send_transactional_email('user@example.com', template_id=3,
attachments=[Path('/path/to/invoice.pdf')])update_campaign replaces the whole attachment set each call: with media_ids=None it re-sends the campaign's existing media, media_ids=[] clears them, and a new list swaps them. It also silently drops a send_at that is already in the past so a stale schedule doesn't fail the update. The default Listmonk server only allows image extensions in the media library, and there's no delete-media endpoint in this client.
Creating a campaign does not send it. A campaign starts as a draft and only begins delivering once its status becomes running, which is a separate endpoint — update_campaign ignores status.
from listmonk.models import CampaignStatuses
campaign = listmonk.create_campaign(name='June', subject='Our June Update', body='# Hi')
# Send a test copy first. Each address must already be a subscriber; the campaign
# stays a draft and its stats are untouched.
listmonk.test_campaign(campaign.id, ['you@example.com']) # -> True
listmonk.start_campaign(campaign.id) # or set_campaign_status(id, CampaignStatuses.running)
listmonk.pause_campaign(campaign.id) # halt, resumable with start_campaign()
listmonk.cancel_campaign(campaign.id) # stop for goodstart_campaign, pause_campaign, and cancel_campaign are thin wrappers over set_campaign_status(campaign_id, status); reach for set_campaign_status directly for the other members of CampaignStatuses (draft, scheduled, finished). All of them return the updated Campaign, or None if no campaign has that ID. Prefer the enum over a raw string: an unrecognized status reaches Postgres and surfaces as an opaque HTTP 500. A rejected but valid transition (starting an already-finished campaign, say) raises httpx2.HTTPStatusError.
create_campaign(send_at=datetime.now() + timedelta(hours=1)) schedules a send. content_type is 'richtext' | 'html' | 'markdown' | 'plain' for campaigns; transactional email uses 'html' | 'markdown' | 'plain' and defaults to 'markdown'. Custom email headers are a list of single-entry dicts (e.g. [{'X-Priority': '1'}]), not a single dict.
Every page on the documentation site has a plain-Markdown twin: swap the .html extension for .md to get token-efficient source without the site chrome. For example https://mkennedy.codes/docs/listmonk/reference/subscribers.html is also available at https://mkennedy.codes/docs/listmonk/reference/subscribers.md. Prefer the .md form when reading these docs programmatically.