Skip to content

Powered by Grav CMS + Helios Theme

Campaigns

This page covers writing a campaign, checking it, sending it, and reading the numbers afterwards. It is for the store owner who is about to put a subject line in front of the whole list.

A campaign is one message, one audience and one set of numbers. You write it in Markdown, it goes out inside the store's own email layout, and every recipient gets their own copy with their own unsubscribe link in it.

When you press Send, the store takes a snapshot of the list: one row per recipient, written before a single message goes out. Somebody who joins the list an hour later is not in this campaign — though you can send it on to them afterwards without writing a second one. See People who joined after it went out.

Before you begin

  • A list with subscribed people on it, and a From address. See Getting started.
  • The worker running. A campaign is one job on the store's queue.
  • kahunacart.newsletters.manage to write one, and kahunacart.newsletters.send to start it.

Write a campaign

  1. Go to Newsletters → Campaigns and click Write a campaign.
  2. Enter a name. Only the store sees it.
  3. Enter a subject and a preheader.
  4. Write the body in Markdown.
  5. Pick a layout.
  6. Pick the list, and optionally a segment.
  7. Click Save.

The campaign is saved as a draft. Messages are queued and sent only after you start it.

The sending controls are under the form, below the Save button: Send a test first, and Send it under it. They appear once the campaign is saved, because there is nothing to send a test copy of until there is a saved campaign. On a campaign you have not saved yet the Send it panel is still there and says so.

The preheader is the grey line most mail clients show next to the subject. Leave it empty and the client shows the first words of your body instead, which is usually a greeting.

The body is Markdown. Bold, lists, headings, links, tables and images all work the way they do in a page, and raw HTML is passed straight through. The layout, the header band and the footer are drawn around whatever you write.

The layout is store or plain. store is the branded card. plain drops the card, the header band and the colours and leaves the words on white, for a message that has to read as a personal email. Both carry the same footer.

A campaign can be started from a template, which copies its fields once. Editing the template later changes what the next campaign starts from and nothing about any campaign already made.

Editing is allowed from draft and scheduled only. A campaign that is sending has already put its subject in somebody's inbox.

Merge tags

Anywhere in the subject, the preheader or the body you can write {{ subscriber.first_name }} and get the person's name.

Variable Is
subscriber.name Their full name, as they gave it. Empty when they never did.
subscriber.first_name The first word of it. Empty when there is no name.
subscriber.email Their address.
store.name What the store calls itself.
store.url The store's own address.
campaign.name This campaign's name. Useful in a footer, not in a greeting.
unsubscribe_url Their own unsubscribe link. Already in the footer; this is for putting one in the body as well.
preferences_url Their own preference centre link.
browser_url This message, on the web, for a client that will not render it.
coupon.code The coupon code attached to this campaign, or empty.
coupon.url The store, with that coupon already applied. Empty when there is no coupon.
customer.order_count How many completed orders this person has, or 0.
customer.last_order_at The date of their most recent completed order, as 2026-08-01, or empty.
customer.group The customer group they are in, or empty.

On a store that sells in more than one currency, a minted code carries the amounts of its campaign's template. The link in the email is ?coupon=CODE with no currency, so a recipient shopping in a currency the template has no amount for finds that the code doesn't apply there. Give the template an amount in each currency you want it to work in. See Coupons.

Anything not on this list is refused when you save, with the variable named and the field it came from.

Not everybody has a name. A subject written as {{ subscriber.first_name }}, we are open reaches somebody who never gave one as , we are open. Write {{ subscriber.first_name|default('there') }} instead.

You also get {% if %} and {% set %}, so {% if subscriber.first_name %}Hi {{ subscriber.first_name }}{% else %}Hello{% endif %} reads properly for everybody. Loops, includes and everything else are not available.

The filters are the same short list core allows in a SKU format or an editable email subject: upper, lower, capitalize, title, trim, replace, slice, format, date, default, number_format, abs, round, length, join, split, first, last, escape, striptags.

Every variable exists for everybody. Somebody who has never bought anything has customer.order_count of 0 rather than nothing.

If a merge tag breaks at send time, the campaign goes out with the tag printed as you typed it and the store writes one line to the log.

Check it before it goes

The preview renders the real message through the real layout with a sample subscriber, and updates three hundred milliseconds after the last keystroke. Tracking is off, so looking at your own draft does not count as an open and does not write to the per-link table.

A test send mails you a real copy through the real transport, with [Test] on the front of the subject. It is the only way to see what your campaign looks like in the client you use.

A test send never touches the campaign: no send rows, no counters, no link rows, and the test address never appears in the audience. If the test address is already a subscriber, it is used as it is.

A new test address gets a subscriber row, because the links are signed with a subscriber's own secret. That row is named "Test" and is kept out of the subscribers table, the chips, the Overview tiles, the growth line and the CSV export. See Subscribers, lists and tags.

Choose who gets it

A campaign names a list, and that is the whole audience unless you narrow it with a segment.

Only the segments that apply to this list are offered: the ones limited to it, and the ones limited to nothing. Choosing one changes the count under the picker to how many of the list will be mailed, with a line under it saying who is left out and why.

Segments are built on the Audience screen. See Segments.

Send it

Open the campaign, click Edit, and scroll to the bottom of the form. Send a test takes an address and sends one copy to it. Send it under that has a Send at box beside the button: leave it empty and the button reads Send it now, put a date and time in it and the same button becomes Schedule it.

Pressing Send does three things in this order.

  1. The audience is written. Every member of the list who matches the segment gets a row. Anybody suppressed, anybody who left this list and anybody who never confirmed gets a row too, marked skipped with the reason, so "38,214 people, 412 excluded" is a number you can click through. If that leaves nobody, the campaign is refused before anything is queued.
  2. A job is queued. One job for the whole campaign, not one per person.
  3. The campaign moves to sending, with that job's id on it.

Sending is behind a confirmation that names the number of people it is about to reach.

From there the worker takes over. Each run of the job claims up to sending.batch people (fifty by default), renders and sends each one, then hands the worker back. It comes straight back for the next batch unless the per-minute throttle or its own forty-second budget stops it.

The throttle is sending.per_minute, sixty by default. It is a rolling window read off the send rows, so it holds across a worker restart, two workers, or a campaign resumed an hour later. Set it to whatever your mail provider is comfortable with.

Scheduling a campaign — a date and time in Send at, then Schedule it — queues the same job with a start time on it. The campaign moves to scheduled and sits there until its moment. It can still be edited, paused or cancelled before it starts.

Test two subject lines

Under the subject box is a Test two subjects checkbox. Tick it and you get a second subject, a slider for how much of the list is tested, and a menu of how long to wait: one, two, four, eight or twenty-four hours. Under those is a sentence in numbers: "482 people get one of the two subjects now; the other 1,928 get the winner at 4:15 PM".

Only the subject differs between the two copies. The body, the links, the layout and the headers are identical.

Here is what happens, in order.

  1. The audience is written exactly as it always is, one row per person, before anything goes out.
  2. The sample is picked and split by a hash of each send row's id, not the first N rows. The odd person goes to A. A campaign interrupted and resumed sends the same people the same subject.
  3. The sample goes out at the store's usual throttle, and the job puts itself back in the queue with a start time at the end of the window. A worker restart or a deploy during the wait costs nothing.
  4. The winner is picked at the end of the window and the rest of the audience is sent it. The winner is the subject more people opened, or, on a campaign that is not counting opens, the one more people clicked. A tie goes to the first subject, and so does a test in which nobody in the sample did anything.

If the sample is still going out when its window is up, nothing is decided until it has finished.

The sample size is between ten and fifty per cent. Below ten the difference is inside the noise; at fifty there is barely anybody left to send the winner to.

Pause and cancel work at every point, including in the middle of the wait. Resuming picks the test up where it was left.

The campaign's own screen shows the test while it runs: both subjects, how many people are in each half, what each has opened and clicked, and when the winner is picked. Afterwards the winner is marked and the loser is struck through. The recipients table grows a Subject column showing which of the two each person was sent.

Those numbers are always the sample's, never the whole campaign's. Everybody who receives the winner after the decision is recorded separately.

Campaign links that point back at your own store carry utm_source, utm_medium, utm_campaign and utm_content, so Google Analytics, Plausible, Fathom, Matomo and Umami can tell you which visitors arrived from a campaign.

The store's defaults are on the Settings screen's Tracking tab: newsletter, email, the campaign's own name, and nothing. Every campaign can override any of them under How links are tagged in its editor, which shows a live example of the link a reader will get.

Three rules.

Only your own links are tagged. A campaign that links to a supplier, a news article or somebody's post is left exactly as written. A link to www. your own domain counts as your own.

What you tagged yourself is left alone, one parameter at a time. A link you wrote as ?utm_source=twitter keeps that source and still gets the other three.

Nothing else in the query is touched. The rest of the link comes out byte for byte as you wrote it, encoding and all.

Leave a campaign's box empty and the store default is used, so changing a default later moves every campaign that never overrode it. Leave utm.campaign empty everywhere and each campaign uses a slug of its own name: september-news for a campaign called "September news".

Note

Tagging rides on link tracking. A campaign with Count clicks turned off has no links rewritten at all, so it carries no UTM either. Set utm.enabled to false to turn tagging off while keeping click tracking.

Hear when it has gone out

The Notifications screen has a Newsletter Campaign Finished row under Store owner. Leave it following New messages go to, or tick a channel on it, and when a campaign has gone out to everyone it was aimed at you get one message with the campaign's name, how many were sent (and how many failed, if any) and a link to it. It is a Quiet message, so it arrives without a sound. A campaign that pauses, is cancelled or has more still to send says nothing, and a campaign sent on to newcomers later does not send a second message. A campaign that was scheduled and then sent in a single run counts as finished too.

Pause, resume, cancel

Pause stops within one message rather than at the end of the batch. The job checks between every recipient, so a Pause pressed while the thirty-first message is going out means the thirty-second does not.

Resume queues a fresh job rather than waking the old one. The new job claims what is still pending, and there is one send row per person per campaign, so nobody is mailed twice.

Cancel is final. The pending rows are left where they are rather than deleted, so you can still see who the campaign did not reach.

Delete is offered only for a draft or a cancelled campaign. Anything that has sent a message is refused: its recipient list is the only record of who received it.

People who joined after it went out

A campaign's audience is fixed the moment it starts, so the people who join the list afterwards never receive it. That is usually right. Occasionally it is exactly wrong: you announce something on a Sunday, forty-one people get it, two more sign up that evening, and those two are the ones who most wanted it.

On a campaign that has finished, Send to newcomers sits beside the other buttons.

  1. Open the campaign from Newsletters → Campaigns.
  2. Click Send to newcomers.
  3. Read the question, which names the number: "3 people joined after this went out and never received it. Send it to them now?"
  4. Click Send it to them.

The campaign goes back to sending, mails those three, and finishes as sent again.

It is the same email: the same subject, the same body, the same links and the same tagging, because it is the same campaign rather than a copy of one. There is still one campaign in the list, one recipient table and one set of rates, and the newcomers are counted in with everybody else. That is the reason to press the button rather than duplicate the campaign — two campaigns for one announcement means two reports, neither of which is the answer.

What it will not do

Send to anybody twice. The audience is worked out again rather than extended, and everybody who already has a row on this campaign is left as they are. That includes the people it decided not to mail the first time: somebody suppressed, or who had already left the list, has a row saying so, and a catch-up does not reconsider them.

Ignore the segment. A campaign sent to a segment catches up only the newcomers who also match it. A campaign whose segment has since been deleted, or whose fields came from an add-on that is now switched off, is refused with the reason rather than quietly widening to the whole list.

Re-run a subject test. If the campaign tested two subjects and picked a winner, the newcomers get the winner. No second sample and no second wait, and the two rates on the campaign's screen stay the sample's, so they are still comparable to each other.

Reuse a coupon code. On a campaign that hands everybody their own code, each newcomer is minted one of their own and nobody who already has a code has it changed. The confirmation says so.

If nobody has joined since, the button is there but off, with a line under it reading "Everybody on the list has had this." Press it again a month later and it does the same for whoever has joined in the meantime. The campaign's screen keeps count: "Sent to newcomers 2 times, last on 14 Sep 2026."

Sending to newcomers needs kahunacart.newsletters.send, like starting a campaign does.

Note

This does not replace the Was sent campaign segment condition, which is still the way to build a different follow-up: a second message, written differently, to the people a campaign did or did not reach. Use the button when you want to send the same thing to people who missed it, and a segment when you want to say something new to them. See Segments.

When something goes wrong

A message the mail provider refuses is marked failed on that person's row, with the provider's own words on it, and the campaign carries on.

After sending.abort_after_failures refusals in a row (twenty-five by default), the campaign pauses itself and an alert appears on the admin's Needs attention strip. The same alert goes to your notification channels as an urgent message, and a "Cleared" one when it is dealt with (KahunaCart 1.5.3 or later). One success resets the count, so a list with a scattering of dead addresses finishes normally and only a broken transport trips it.

To recover: fix whatever is wrong with the mail settings, then resume. Nobody who already received it will get it twice.

If the whole transport is unavailable (no mail plugin, no credentials at all), the job fails rather than the campaign, and the queue retries it with its usual backoff.

The numbers

Every campaign carries counters: total, sent, delivered, opened, clicked, bounced, complained, unsubscribed, failed. They are a cache of what the send rows say, and every one of them except unsubscribed can be rebuilt from the send rows on demand.

unsubscribed is the exception because leaving a list is recorded on the membership rather than on the send. A rebuild leaves it alone rather than resetting it to zero.

Where each number comes from

Number Written by
sent, failed The store's own send job.
opened The store's open pixel, and a click. A provider webhook can also write it where the provider does its own tracking.
clicked The store's click redirect, and a provider webhook where the provider tracks clicks.
delivered, bounced A provider webhook only. Nothing in a browser tells you that a receiving server accepted a message or handed it straight back.
complained A provider webhook only.
unsubscribed Counted at the moment somebody leaves through this campaign's link.

Where both the store and a provider report the same thing, the first one wins and neither is counted twice. The status ladder never moves a send backwards.

Important

"Accepted" and "Delivered" are two different words. A store with no provider webhook has never been told that anything landed, so the last-campaign panel says Accepted and shows what the transport took off the store's hands. Once any delivery event has ever arrived, the same card says Delivered and counts the send rows carrying a delivered_at stamp. A campaign sent before the webhook was set up shows zero delivered.

What is counted, and what is never stored

An open is a timestamp. A click is a link, a person and a moment. No IP address, user agent, browser, location, device or city is collected or stored, and there is nowhere in the database for it to go.

The open pixel. When tracking.opens is on, the message carries an invisible one-by-one image just before the end of the document. Only the first fetch counts: a client that reloads images fifty times is one open, and the date stays as the first read.

Do not trust the open count as an absolute number. Apple Mail Privacy Protection fetches every image whether or not the recipient opened the message, and other clients block images by default. Use the open rate to compare your own campaigns against each other over time.

The click redirect. When tracking.clicks is on, every outbound link is rewritten to point at {base}/newsletter/c/{token}. Following it records the click and sends the reader straight on.

Always left alone: mailto: and tel: links, anchors within the message, the unsubscribe link, the preference centre link, the view-in-browser link, and anything that is not http or https.

  • One row per URL, not per appearance. The same address written twice in one campaign is one link with one running count.
  • A click counts as an open. Most clients that block images do not block links, so for many readers a click is the only evidence the message was read.
  • Every press is recorded, one person is counted. The clicks table takes a row per press, which is what draws the first 48 hours as a chart. The campaign's clicked counter goes up once per person.
  • A forwarded email cannot count against somebody else. The token binds the person, the message and the link together and is signed with that person's own secret.

Both switches are store defaults and a campaign can override them. With both off, the add-on still sends campaigns, honours unsubscribes, records who was sent to and writes to the store's email log.

tracking.retain_days (365 by default) is how long individual click rows and provider event rows are kept before the retention sweep removes them. The campaign's own totals and each link's own count survive the sweep, so a campaign from three years ago still says how it did.

The campaign detail screen

The counters come first, then the first forty-eight hours as a chart across the full width, then the campaign's own details beside the per-link clicks, ordered by what worked. The recipient table is underneath, filtered by what happened to each person. A failed row carries the transport's own words.

The chart draws one cumulative line per thing being counted (sent to, delivered, opened, clicked), so every line only goes up and the point where a line flattens is where that campaign stopped happening. A line the campaign is not counting is left out rather than drawn flat along the bottom. The ticks along the bottom count hours from the moment the first message left.

The headers every campaign carries

Header Says
List-Unsubscribe Two ways out: a mailto: to your own from address, and the recipient's own unsubscribe link.
List-Unsubscribe-Post That the link above accepts a one-click POST (RFC 8058), which is what makes Gmail and Outlook draw their own unsubscribe button next to your name.
Precedence: bulk Keeps out-of-office replies from coming back at you.
X-KC-Campaign Which campaign this was, for when you are reading raw headers in a support conversation.
Message-ID Generated by the store rather than by the transport, so the same string is on the message, on the send row and in the store's email log.
X-Grav-Send-Id Which send row this message is, so a provider that echoes it can tie a bounce to one person. See Delivery reports and providers.

From Grav Email plugin 5.0.9 that plugin writes the headers itself, replaces a header rather than adding a second one, and logs anything it could not put on the message to logs/email.log and to Grav's own log. On anything older this add-on writes them itself.

Every link in every message a person has ever been sent is signed with that person's own secret alongside the store's. Rotating one subscriber's secret invalidates every one of those links and nobody else's. Do this when somebody says "I am getting mail from a link I never signed up for". A token minted on a staging site does not work on production.