Skip to content

Powered by Grav CMS + Helios Theme

Notifications

Emails go to customers. Notifications go to you: your inbox, a Slack channel, a Discord server, Telegram, a text message, your phone (Pushover, ntfy or your own Gotify server), or an endpoint of your own. This page covers the Notifications screen, setting up each kind of channel step by step, the messages you can get, and the webhook payload.

Before you begin

  • Put Grav's scheduler in cron. Notifications are queued and delivered by bin/plugin kahunacart work. See CLI.
  • Give the store its address with Site Addresses under Settings → Security (security.site_urls), or system.custom_base_url in user/config/system.yaml. Without either, messages recorded from the command line go out with no admin link. See Admin links need a base URL.
  • You need kahunacart.settings to set a channel up.

The Notifications screen

KahunaCart → Notifications lists every message the store can send. A To filter at the top narrows it:

Filter Shows
All Everything
Customers The emails that go to the person who bought something
Store owner The messages about your store that come to you

The choice is remembered in your browser, so if you only ever want your own rows, set it once.

Every row carries what the message is called, a one-line description, its code, who it goes to, what makes it go out, a switch, and a "…" menu. The column in the middle is what differs:

  • A customer row has a Template picker — the shipped pair, or another from your theme. Restore puts the shipped one back.
  • A store-owner row has Channels: a chip per channel you have set up, ticked or not. Click a chip to tick it. Kinds you have not set up are not shown on the rows; you add them on the Channels page.

A store-owner row you have never set up follows New messages go to (next section), and says so: Default (Slack, Discord) under its chips, which show the default ticked. Tick or untick a chip and the row has a list of its own from then on. A row with its own list has Use the default under its chips and in its "…" menu, which sends it back.

Important

A store-owner row that goes nowhere reads Nowhere yet, or Nowhere when you unticked every channel on it yourself. The switch still works — the row is on, and it is going nowhere. Tick a channel, or choose where new messages go, or the message is not sent.

New messages go to

The Channels page has one setting above its list: New messages go to, a chip for each channel you added there. Every store-owner message you have not given channels of its own goes to the ticked ones. That includes every message an add-on brings later, so installing Affiliates or Bookings puts their messages in your Slack without you hunting for their rows.

  • A row follows the default until you touch its channels. Ticking or unticking a chip on a row gives it a list of its own, and unticking every chip is a choice too: that row goes nowhere. Switching a row on or off, or changing its priority, does not stop it following the default.
  • Use the default on a row with its own list sends it back.
  • Adding a channel has a Use for new messages box. It starts ticked for your first channel and unticked after that.
  • Removing a channel takes it out of the default as well as off every row. A row whose own list loses its last channel this way goes back to the default, because you removed a channel, you did not choose "nowhere".
  • Each row's switch still decides whether it goes out at all. An add-on can bring a row that starts switched off, such as one message for every affiliate commission, and it stays off until you switch it on, default or not.

Nothing is chosen for you on upgrade. Every row you already set up keeps its channels, and a store with no default sees no change. A store that has a channel, no default and switched-on messages going nowhere gets one dashboard alert, "4 store-owner messages aren't sent anywhere", with a button to the Channels page. Choosing a default ends it, choosing none ends it too, and hiding it keeps it away for 30 days.

Setting up each channel

Open KahunaCart → Operations → Notifications and press Set up channels at the top right — it reads Channels (n) once you have some — which opens the Channels page. Under Add a channel every kind is a card, grouped into email and chat, push to your phone, text message and webhook, and a kind you already have says how many. Press Set up on one, fill in the boxes, add it, then press Send test on its row. Choose another kind goes back to the cards. The test goes through the real queue and the real dispatcher to that one channel, so a message arriving is proof the whole path works, and one that does not arrive shows you the service's own error.

Note

Secrets are encrypted with your store's own key before they are written, the same way a payment provider's API key is, and they are never shown again. Leave a secret box alone when you edit a channel and the saved value is kept.

Every row on the Channels page says where it goes: an email channel shows its addresses, Telegram its chat id, a text message the phone number it goes to, ntfy its server and topic, Gotify its server, and a Slack, Discord or webhook URL shows its host and path with the token part taken out — the URL itself is the secret for those three, so it is never printed in full. Pushover shows nothing beyond the kind, because its user key and its application token are both secrets. Edit in a row's "…" menu opens the same form again at an address of its own, with the label, the switch and every non-secret box filled in and every secret box empty: leave a secret blank to keep what is saved, and type in it only when you want to replace it.

Email

  1. Channels → Email.
  2. Put one or more addresses in, one per line or separated by commas. They all go on the same message.
  3. Send test.

The message goes out through your store's own mail setup, so it appears in Emails → History beside everything else the store sends, retries the same way, and carries the same From address. That address comes from the Email plugin's from and from_name settings — the same place every other message the store sends gets it. If your store's email works, this works.

Slack

  1. Open your Slack app's settings at api.slack.com/apps, or create an app if you have none.
  2. Incoming Webhooks → Activate Incoming Webhooks → Add New Webhook to Workspace.
  3. Pick the channel you want the messages in, and copy the https://hooks.slack.com/services/… URL.
  4. Channels → Slack, paste it, Send test.

No scopes, no bot, no versioned API. A webhook URL is tied to the one channel you picked, so a second Slack channel means a second webhook.

Discord

  1. In your server: Server Settings → Integrations → Webhooks → New Webhook.
  2. Pick the channel, then Copy Webhook URL.
  3. Channels → Discord, paste it, Send test.

Mentions are stripped from every message KahunaCart posts, so an order from a customer who has named themselves @everyone cannot ping your server.

Telegram

Telegram has no incoming webhook — you send through a bot of your own. Two values, and both come from Telegram.

Get the bot token

  1. In Telegram, message @BotFather.
  2. Send /newbot. It asks for a name (anything — "Acme Store Alerts") and then a username, which has to end in bot (acme_store_alerts_bot).
  3. It answers with a token that looks like 123456789:AAHk3f…. That is the bot token.

Get the chat id

This is where the bot sends. Three cases:

  • To yourself — message @userinfobot and it replies with your numeric id. Then message your own bot once. Telegram will not let a bot open a conversation with you, so you have to speak first or the send fails with "chat not found".
  • To a group — add your bot to the group, send any message in it, then open https://api.telegram.org/bot<your token>/getUpdates in a browser. The chat.id in the answer is the one you want.
  • To a channel — add the bot as an administrator of the channel and use @thechannelname as the chat id.

Then Channels → Telegram, paste both, Send test.

Warning

A group's chat id starts with a minus sign, like -1001234567890. The minus is part of the id — paste the whole thing.

What the two failures mean:

What Telegram says What is wrong
401 Unauthorized The bot token is wrong. Check you copied the part before the colon too.
400 chat not found The chat id is wrong, you have not messaged the bot yet, or the bot is not in the group.

Messages are sent as HTML with the headline in bold and the facts one per line, so a product name with an underscore or an ampersand in it arrives as itself.

Pushover

Push to your phone, from a company that sells an app rather than a subscription. The app is a one-time purchase per platform; sending is free up to 10,000 messages a month per application, which no store is going to reach with these.

  1. Sign up at pushover.net and install the app on your phone. The phone that receives the notifications needs the app licence — that is the one-time purchase, and it is per platform rather than per store.
  2. Your user key is on the front page once you are signed in, next to the QR code. It is a 30-character string.
  3. Create an application at pushover.net/apps/build. Name it after your store, pick "Website" as the type, and upload an icon if you want your logo on the notification. It answers with an API token/key — that is the application token.
  4. Channels → Pushover, paste the user key and the application token, Send test.

You can set a sound on the channel — one of Pushover's own names, cashregister being the obvious one for a sale. Leave it empty for your account default.

Priorities are what make a phone channel bearable. Each message declares one, and you can change it per row from the "…" menu:

Priority What Pushover does
Quiet Arrives without a sound or a vibration
Normal The usual notification
Urgent Bypasses your quiet hours
Emergency Repeats every minute for an hour until you acknowledge it in the app

Caution

Emergency is reserved for Dispute opened out of the box. A chargeback has a deadline set by somebody else and you lose the money by missing it, which is the one thing worth being woken for. Put anything else on emergency and you will mute the channel within a week.

ntfy

The free alternative, and you can run the server yourself. ntfy.sh publishes to a topic, and anyone who knows the topic name can read it.

  1. Install the ntfy app on your phone.
  2. Pick a topic name nobody could guess. There is no password on a plain ntfy topic, so the name is the password.
  3. Subscribe to that topic in the app.
  4. Channels → ntfy, leave the server at https://ntfy.sh, put your topic in, Send test.

Warning

Treat the topic name like a password. acme-store-8f3k2p is fine; orders is a topic you share with every other person who thought of orders.

Running your own server changes the Server box and nothing else — but one caveat is worth reading before you do. iPhone delivery still goes through ntfy.sh even from a self-hosted server. Apple only lets an app receive pushes through its own service, so a self-hosted ntfy forwards to ntfy.sh's relay to reach an iPhone. Android gets messages straight from your server. If keeping the messages off somebody else's infrastructure is why you are self-hosting, that is the part to read twice.

Access tokens are for a server that requires one — your own with authentication on, or a reserved topic on ntfy.sh. Make one under Account in the ntfy web app and paste it in. Leave it empty otherwise.

ntfy carries the same priority as Pushover, on its own scale: quiet is 2, normal 3, urgent 4, emergency 5.

Gotify

Push to a Gotify server you run yourself. Nothing goes through anybody else's service, and the Gotify Android app shows the messages on your phone. There is no official iPhone app, so for an iPhone use Pushover or ntfy.

  1. In the Gotify web UI, open Apps, press Create Application, call it something like KahunaCart, and copy its token.
  2. Channels → Gotify, put in your server's address (https://gotify.example.com, the one you open the web UI on), paste the app token, Send test.

Warning

It has to be an app token, from Apps. A client token, from Clients, is for reading messages, and Gotify refuses it for sending with a 401.

The token goes in a header, never in the URL, so it stays out of your server's access log. The store has to be able to reach the server: a Gotify on your home network works for a store on the same network, and not for one hosted somewhere else.

Gotify carries the same priority as Pushover, on its own 0–10 scale: quiet is 2, normal 5, urgent 8, emergency 10. The Android app makes a sound from 4 and pops the message up from 8, so urgent and emergency messages get your attention and quiet ones wait in the list. Tapping a message opens it in the admin.

Text message (Textbelt)

A text to a phone through Textbelt, the quick way to get one going. There is no number to buy and nothing to register with the carriers: you buy a pack of texts, paste the key, and it works. For your own phone, Pushover, ntfy or Gotify is cheaper and faster. Text messages are for when an app is not an option: a partner or a shop-floor phone with no app on it, or a number that has to work with no data connection.

Packs are one-off, from $3 for 50 texts, with no subscription. Textbelt counts your pack in SMS segments, so a long message uses more than one of your texts, and a message here is cut at 480 characters. Textbelt's FAQ has the details.

  1. Buy a pack at textbelt.com and copy the key. To try it first, the key textbelt sends one free text a day.
  2. Ask Textbelt to allow links for your key. Our text ends with the link to the order in the admin, and Textbelt refuses any text with a link in it until it has allowed links for that key. Open https://textbelt.com/whitelist?key=YOUR_KEY with your own key in place of YOUR_KEY, once, before you press Send test. Link shorteners are never allowed, which is fine, because the link goes out as it is.
  3. Channels → Text message (Textbelt), paste the key into API key, and put the phone that should get the texts in Send to.
  4. Send test.

To check the setup without sending a text or using any of your pack, put _test on the end of the key and press Send test.

Warning

Take _test off the key again when you are done, or nothing will ever reach the phone.

Which one to pick. Pick Textbelt when you want it working in five minutes with nothing to register, and you do not mind the texts coming from a number Textbelt owns and shares. Pick Twilio when you want the texts to come from your own number, or you already have a Twilio account.

Phone numbers take a country code and are forgiving about the rest: +1 (555) 123-4567 is sent as +15551234567. A number with no + is refused rather than guessed at, because a text to the wrong country is billed all the same.

Textbelt answers 200 even when it refuses a text. KahunaCart reads the answer itself, so a refusal such as running out of texts, or a link Textbelt has not allowed yet, shows up under Send test in Textbelt's own words. Textbelt repeats your key inside some of those words, so KahunaCart hides it before the line is logged or shown. Textbelt accepting a text is not the phone getting it. Send test reports that Textbelt took it.

For a second phone, add a second channel.

The text is the same one the Twilio channel sends, shown under Twilio below. It is posted to Textbelt as a form with the phone, the text and your key. Priority has nowhere to go in a text message and is ignored.

Text message (Twilio)

A text to a phone, sent through your own Twilio account. For your own phone, Pushover, ntfy or Gotify is cheaper and faster. Text messages are for when an app is not an option: a partner or a shop-floor phone with no app on it, or a number that has to work with no data connection. Twilio is the one to pick when the texts should come from your own number. If you would rather have nothing to register, Textbelt is quicker.

Each text costs about a cent in the US and more elsewhere, billed by Twilio per 160-character segment. A message is cut at 480 characters, three segments, so one busy order cannot cost more than a few cents.

  1. Sign up at twilio.com and open the console. The Account SID and the Auth Token are both on the front page.
  2. Buy a phone number in the console for the texts to come from. If you already have a Messaging Service, you can use its SID instead, the one that starts with MG.
  3. In the US and Canada the number has to be approved before carriers will deliver from it. A toll-free number needs toll-free verification, which is free and takes a few business days. A local number needs A2P 10DLC registration. Until that is done, Twilio takes your messages and then blocks them before they reach the phone.
  4. Channels → Text message (Twilio), paste the Account SID and the Auth Token, put the number or the Messaging Service SID in From, and the phone that should get the texts in Send to.
  5. Send test.

Phone numbers take a country code and are forgiving about the rest: +1 (555) 123-4567 is sent as +15551234567. A number with no + is refused rather than guessed at, because a text to the wrong country is billed all the same.

Twilio accepting a message is not the phone getting it. Send test reports that Twilio took the message. If it says sent and nothing arrives, open Monitor → Logs → Messaging in the Twilio console, which gives the carrier's reason for each message. A US or Canadian number that has not been approved yet is a common one.

For a second phone, add a second channel.

The text is the headline, the facts one per line and the admin link on the last line. ×, the dashes, curly quotes, … and · are folded to plain ASCII so the message stays in 160-character segments, and the whole message is cut at 480 characters with the link kept whole. A customer's name in another alphabet arrives as written, and costs shorter segments. Priority has nowhere to go in a text message and is ignored.

TXT
Acme Supply: New order #1042
Customer: Jane Doe
Total: $28.00
Items: 2 x Widget Pro, Gadget (Blue)
Payment: Paid
https://store.example/admin/plugin/kahunacart#/orders/7

Webhook

Your own endpoint, which gets the whole event as JSON.

  1. Channels → Webhook.
  2. Put your http or https URL in.
  3. Optionally set a signing secret — any long random string. Every request then carries an HMAC-SHA256 of the body in X-KahunaCart-Signature, so your endpoint can prove the request came from your store.
  4. Send test.

See the webhook payload and Verify a webhook signature.

The messages that come to you

Nineteen rows, filtered to Store owner, plus a row for each dashboard alert that is pushed. All of them are on by default, and none goes anywhere until you tick a channel or choose where new messages go — except the store copy, which is an email and says where it goes on its own row.

Good news

Message Goes out when Priority
Sale An order completes Quiet
Payment landed Money arrives for an order that was waiting on it Quiet
Refund went through A refund the provider confirmed, full or partial Normal
Order cancelled An unpaid order is called off Normal
Store copy of the confirmation With the customer's confirmation, to the address in Send Order Copies To —
Stock alert requested A shopper's back-in-stock or launch alert becomes active Normal
Quote answered A customer accepts or declines a quote Normal
Review waiting for approval A review is written and cannot go live until you approve it Quiet

A sale, a payment and a cancellation name what was bought. Items: 2 × Widget Pro, Gadget (Blue) — the product as it was named on the order, the variant after it where the variant has a name of its own, and the quantity in front where more than one was bought. Three products are named and the rest are counted (Anvil, Bellows, Chisel + 4 more), because the message has to fit on a lock screen and the order is one tap away. A variant whose name is already in the product's name is not repeated, so "KahunaCart — One site" with a variant called "One site" reads once rather than twice. A refund names no products: the message knows the order's lines but not which of them the money came back for, so it gives you the amounts instead.

Stock alert requested tells you somebody asked to be told when a product is back in stock or launches. A guest's alert counts when they click the confirmation link, not when they type their address, so an address nobody confirmed never reaches you. The message is one sentence, "Someone wants Sandbar Boardshort 32 / Slate back in stock (1 waiting)", with a link to the product in the admin. The count is everyone now waiting on that variant, including the person who just asked, and it never includes the shopper's email address.

Quote answered tells you a customer said yes or no to a quote you sent. It names the order and the total, and there is one message per quote per answer: a double-click on Accept is one message, and a quote that is declined, sent again and then accepted is two. It is not sent when you record an answer yourself from the order screen, for a customer who said yes on the phone, because you already know.

Review waiting for approval tells you a review is sitting in the moderation queue. It names the product and the stars and links to the queue, and it never says who wrote the review. A review that goes live on its own, with moderation off and a verified purchase behind it, needs nobody and sends nothing. One message per review.

The store copy is the one row with no channels. It is the customer's own confirmation email with a second recipient — the address in Send Order Copies To, on the Notifications tab of KahunaCart → Settings — put there by the confirmation itself, so there is nothing to tick and no priority to set. Its row says where it goes instead of drawing chips, its switch works like every other one, and owner.store_copy is not an event a channel can subscribe to.

Amounts in more than one currency

Every amount in a message is in the currency of the order it is about, formatted in it: a euro order reads €52.00 and a yen order ¥7,800, never the store's own currency's symbol on another currency's number. On a store that sells in more than one currency an amount in a currency other than the store's own has its code beside it, €52.00 EUR or ¥7,800 JPY, in every channel, so an owner can tell a yen order from a dollar one at a glance even where the symbols look alike. An amount in the store's own currency is written as it always was, and a store that sells in one currency never sees a code. The currency in the event's data is the order's own, so the generic webhook channel and a receiver reading total_minor have it to hand.

Problems

All but one of these is deduplicated, and the key is in the table. A message that fired on every request it described would be muted within a day and then never read again.

The exception is Refund requested, and it is the only row here that is about a person rather than about a number: two requests are two customers, and one of them waiting while the other's message is dropped would be the wrong kind of quiet. Every request goes out.

Message Goes out when One message per Priority
Refund requested A customer asks for a refund from their receipt never deduped Urgent
Payment failed A card declined, or a payment that would not start order per day Urgent
Webhook rejected The store refused a webhook from a provider provider and reason per day Urgent
Webhook deliveries failing One of your own subscriptions keeps failing subscription per day Urgent
Provider not answering A provider's connection check fails provider, cleared when it passes Urgent
Dispute opened A customer disputed a charge dispute Emergency
Dispute won or lost The provider settled a dispute, and whether you kept the money dispute and outcome Normal
Background jobs stopped The queue is not draining, or jobs keep failing stall, cleared on recovery Urgent
Low stock A variant crossed your low-stock threshold variant per slide under the line Normal
Note on an order The store left a note on an order for you: a payment to check, stock below zero, a coupon used too often, a webhook that gave up, an address that did not validate note Normal
Refund failed The provider turned down a refund the store had submitted refund Urgent
Exchange rates out of date A rate your store uses is older than currencies.rates.max_age_days working days, there is none, or a fetched rate was held because it moved too far, and again when every rate is fresh. Only on a store that fetches its rates episode, cleared when the rates are fresh Normal

Webhook deliveries failing, Provider not answering, Background jobs stopped, Low stock and Exchange rates out of date are checked on the worker's housekeeping tick, because nothing announces them: a queue that stopped draining stopped doing things, including telling you.

Note on an order is the store's notice queue, pushed. Every note the store writes for you becomes one message, in plain words: "Payment needs checking", "Stock went below zero", "Coupon used more than its limit", "Webhook failed", "Address couldn't be verified". Under the heading it names the order and says what the note says. A note from an add-on, such as a renewal payment that needs a person, has no heading of its own, so its sentence is the headline. The notes written for your customers, such as a discount code that lapsed, are theirs and never come here. One message per note.

Refund failed is for a refund you started that the provider then turned down, whether it rejected it, cancelled it or failed it later. The customer has been told their money is on its way and it is not, so it goes out at urgent priority with the provider's own reason. The order's payment status stays where it was. A refund you retry is a new refund, and its failure is another message. A refund the provider refuses on the spot, while you are looking at the order screen, shows its error there and sends nothing.

Dispute opened is sent by the payment provider's plugin when it hears that a customer has disputed a charge. Stripe, PayPal and Mollie do this; Polar sends the store no dispute notice, so a dispute there is only visible in Polar's own dashboard. See Payment providers. The plugin also writes Dispute opened on the order's history, with the amount, the provider's reason and the date you have to answer by, and the date reads as a plain date in the message ("21 Oct 2026"). The order itself is not changed, because you may well win. A store that set up its Stripe or PayPal webhook before disputes were reported has to press Set up webhook once more on that provider's card to subscribe to them.

Dispute won or lost follows when the provider settles it, and says whether you kept the money. Stripe and PayPal report it; for other providers you set the outcome on the order yourself, which sends no message. One per dispute and outcome.

Two of them clear. A provider that starts answering again and a queue that starts draining both lose their stamp, so the next outage is news rather than silence.

Exchange rates out of date sends one message when the problem begins and one when every rate is fresh again. It is never sent for a rate you typed, or on a store that doesn't fetch its rates.

The connection probe runs once an hour rather than every tick, and only when something is ticked for it. It costs a round trip per configured provider, and a provider that went down two minutes ago is not something you can act on faster than that.

Add-ons register their own store-owner messages the same way, so a payment driver's warnings and a subscription's past-due notice appear in this list by name. A row an add-on brings goes to your default channels like any other, unless the add-on starts it switched off. Each add-on's chapter lists its own: Affiliates, Bookings, Shipping, Newsletters, Print on demand, Point of sale, Subscriptions and Licenses.

Dashboard alerts on your phone

Some dashboard alerts are worth hearing about before you next open the admin, so an alert can ask to be pushed. Each one that does is a row here, coded alert.<alert id>, with its own switch, channels and priority. Three of the store's own are: No site address for email links, Worker not using the site's settings and Add-ons too old for the site root, all Urgent. Add-ons add their own the same way.

The background worker checks these on its housekeeping tick, each on the alert's own schedule, and only while something receives the row: a row switched off, or one no channel reaches, costs nothing. The message is the alert's headline, the sentence under it, how bad it is, and a link to where its button goes (the dashboard when it has none).

One message per episode. It goes out when the alert starts showing and says nothing while it stays. When the alert clears, the same channels are told "Cleared: …" at normal priority, but only if the first message went out. Hiding the alert on the dashboard has no effect on any of this, because that is one person tidying their own view. Switching the row off does. An alert whose check errors costs that alert only.

Important

Customer email addresses and postal addresses are never sent to Slack, Discord, Telegram, Pushover, ntfy, Gotify or a text message. Those messages carry the store name, the order number, the customer's name, the amount, the names of the products and the admin link. An order with no billing name shows no name; the email address is not used as a fallback. The product names are your own catalogue text rather than anything the shopper typed — if you would rather your chat channels did not name what a customer bought, send your sales to the email channel or to an endpoint of your own. Your own inbox and your own endpoint are not third parties, so the email channel and the generic webhook do carry the address.

How notifications are delivered

Notifications are queued, never sent inline: a row goes into the job table and the worker posts it on the next tick. A Slack outage cannot slow a checkout down or fail one.

One job is queued per event and fanned out across every matching channel.

  • A channel that fails does not fail the job. The failure is logged with the channel's label and the reason.
  • A delivery is retried only if every channel failed, through the queue's backoff: three attempts, exponentially spaced.
  • Any 2xx is a success. Slack answers 200, Discord 204, and your own endpoint may answer 202. The one exception is Textbelt, which answers 200 to a text it refused too, so its answer has to say success: true.
  • A redirect is not followed and is not a success. Update the URL.

Channels are read at delivery, so a channel added or a chip ticked while jobs are waiting receives them, and a corrected URL applies to the retries.

Channels from the config file

Channels can also live under notifications.channels in user/config/plugins/kahunacart.yaml, which is where they lived before this screen existed. They keep working, and they are listed on the Channels page marked From the config file — read-only, because the file is the record of them.

YAML
notifications:
  enabled: true
  low_stock_threshold: 5
  timeout: 5
  channels:
    - label: 'Sales'
      type: slack
      url: 'https://hooks.slack.com/services/T00/B00/xxxxxxxx'
      enabled: true
      events: [owner.order_completed, owner.payment_received]

    - label: 'Ledger'
      type: webhook
      url: 'https://ops.example.com/kahunacart'
      enabled: true
      events: all
      secret: 'a-long-random-string'

A channel from the config file goes by the events it lists, not by a tick on a row. A channel you added on the Channels page goes by a tick, and carries no event list at all. Both are delivered from the same job, and neither can be reached the other's way, so a channel is never contacted twice for one event.

A row you switched off goes nowhere, a channel from the config file included. A config channel with no events list, or events: all, takes every event whose row is switched on and none whose row is off, including a message that was already queued when you switched it off. Before KahunaCart 1.5.3 such a channel kept receiving a row you had switched off.

Warning

A channel that cannot be read — an unknown type, a blank or non-HTTP url, a Telegram entry with no token — is dropped silently. Check type and url first when a config channel has gone quiet.

A secret on a Slack or Discord channel is discarded, never sent.

The old event names still work

The five events the store already fired were renamed when they became rows on the screen, and every old name is still honoured in a config channel's events list.

Now Was
owner.order_completed order.completed
owner.payment_received payment.received
owner.order_refunded order.refunded
owner.order_cancelled order.cancelled
owner.stock_low stock.low

The events added with this screen (owner.payment_failed, owner.webhook_rejected, owner.webhook_failures, owner.provider_unreachable, owner.dispute_opened, owner.jobs_stalled, owner.exchange_rates_stale, owner.quote_answered, owner.review_submitted, owner.order_notice, owner.refund_failed) have no old name. owner.store_copy is not in that list and cannot be subscribed to at all: the store copy is an email with a fixed recipient, not a channel event.

Important

If you receive any of the five on your own endpoint, read the webhook payload before you upgrade. The old name still reaches your channel, but the event inside the envelope is the new one, and a receiver that switches on order.completed would go on being sent sales and quietly stop matching them. The envelope carries both names, so there is a key to switch on either way.

Verify a webhook signature

Set a secret on a webhook channel and every POST carries an HMAC-SHA256 of the raw request body in X-KahunaCart-Signature, as sha256=<hex>.

PHP
$body = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $body, $secret);

if (!hash_equals($expected, $_SERVER['HTTP_X_KAHUNACART_SIGNATURE'] ?? '')) {
    http_response_code(401);
    exit;
}

Compute the HMAC over the bytes as received, not over re-encoded JSON.

Low-stock notifications

Low stock is a sweep run once per worker tick, so it catches stock moved by a refund, an admin edit, a catalog sync or a CSV import as well as by a sale.

What counts as low is the same query behind the admin's low-stock panel and the /kahunacart/reports/low-stock endpoint. A variant showing amber in the admin earns a message. Variants with stock tracking off are excluded.

Each low variant gets a stamp with no expiry, held in kahunacart_settings under notify.stamp. names. You get one message when it slides under the threshold, silence while it stays under, and a fresh message if it is restocked and slides under again. A sweep sends at most 20 variants, so a backlog drains over successive ticks.

The CLI worker has no HTTP request to read a hostname from. For an event recorded during a request it does not need one: the site's address is captured when the event is queued and travels with it, so an order placed on the storefront produces a message whose admin link works. Only an address the store has named is captured, from security.site_urls or system.custom_base_url, because a request's host comes from a header the visitor can set. See Hardening → Site address for emailed links.

An event recorded from the command line, such as a low-stock sweep run by the scheduler or an admin script, has nothing to capture. Those use the first entry of security.site_urls, or system.custom_base_url in user/config/system.yaml:

YAML
custom_base_url: 'https://store.example'

custom_base_url also wins over the captured address, which is what a store serving several hostnames wants. With neither set, the message goes out with no admin link rather than with a broken one.

Configuration reference

Key Default Effect
notifications.enabled true Master switch for the channels in the config file. false stops them without deleting them.
notifications.low_stock_threshold 5 Stock at or below this earns a low-stock message. 0 means only when it has run out.
notifications.timeout 5 Seconds allowed per channel per POST. Capped at 15.
notifications.channels empty The channels defined in the config file.

Per-channel keys:

Key Default Effect
type none email, slack, discord, telegram, pushover, ntfy, gotify, textbelt, twilio or webhook.
url none The incoming-webhook URL, or your own endpoint. http and https only.
enabled true false leaves a channel configured but silent.
events all all, a YAML list, or a comma-separated string.
label the type What the channel is called in error logs.
secret empty Signs the payload. Generic webhook only.

Webhook payload reference

JSON
{
  "event": "owner.order_completed",
  "alias": "order.completed",
  "occurred_at": 1755561600,
  "store": {
    "name": "Acme Supply",
    "url": "https://store.example",
    "currency": "USD"
  },
  "data": {
    "order_id": 7,
    "number": "1042",
    "status": "completed",
    "payment_status": "paid",
    "fulfillment_status": "unfulfilled",
    "currency": "USD",
    "total_minor": 2800,
    "items_total_minor": 2500,
    "item_count": 2,
    "items": [
      {
        "product_id": 4,
        "variant_id": 9,
        "sku": "WP-2",
        "title": "Widget Pro",
        "variant_title": "Blue",
        "qty": 2,
        "unit_price_minor": 1000,
        "total_minor": 2000
      },
      {
        "product_id": 8,
        "variant_id": 14,
        "sku": "GDG",
        "title": "Gadget",
        "variant_title": "",
        "qty": 1,
        "unit_price_minor": 500,
        "total_minor": 500
      }
    ],
    "customer_id": 31,
    "customer_name": "Jane Doe",
    "email": "[email protected]",
    "provider": "stripe",
    "completed_at": 1755561600,
    "admin_url": "https://store.example/admin/plugin/kahunacart#/orders/7"
  }
}
  • alias is the name the event went by before it became a row on the screen, and it is only there on the five that were renamed. If your receiver switches on the old name, this is the upgrade note: the subscription still reaches you — a notifications.channels entry listing order.completed gets sales exactly as it did — but event is now owner.order_completed, so a switch on the old string would stop matching without any error to read. Key off alias where it is present, or off both, until you have moved across. The same value is repeated in the X-KahunaCart-Event-Alias header for a receiver that routes on headers.
  • items is what was on the order, each line as it was frozen when it went into the cart — never the line's whole snapshot, which holds the product and the variant as they were bought, options and all. variant_title is empty for a product with one unnamed variant, which is most products. item_count counts lines, not units. Both are absent on owner.order_refunded, which knows the order's lines but not which of them the refund was for.
  • occurred_at is a Unix timestamp taken when the event was recorded, not when it was delivered.
  • Money is always minor units paired with currency: 2800 and "USD" is $28.00. See Money.
  • admin_url is present only when the store has an absolute base URL to build it from.

Per-event data

Event Adds
owner.order_completed, owner.payment_received, owner.order_cancelled item_count and items
owner.order_refunded refunded_minor (the running total refunded, not this refund's slice) and full, and no items
owner.stock_low product_id, product_title, variant_id, variant_title, sku, stock_qty, threshold. variant_title is the option combination ("Navy / M") for a product with more than one variant, so each size is its own message, and blank for a product with one
owner.payment_failed order_id, number, provider, provider_name, amount_minor, currency, reason
owner.webhook_rejected provider, provider_name, reason, status, streak. An add-on that takes webhooks at an endpoint of its own sends it the same way, with its own name in provider
owner.order_notice order_id, number, notice_id, type (the notice's code, such as payment_attention), severity, title (the heading in plain words, blank for a type the store has no words for) and message, the notice's own sentence in the store's language
owner.refund_failed order_id, number, transaction_id (the refund's row in the ledger), provider, provider_name, amount_minor, currency, reason
owner.review_submitted review_id, product_id, product_title, rating and state (always pending). Not the reviewer's name or email
owner.quote_answered The order's usual keys, and answer, accepted or declined
owner.webhook_failures subscription_id, subscription, topic, target_url, failures, status, last_error
owner.provider_unreachable provider, provider_name, reason
owner.dispute_opened order_id, number, provider, provider_name, dispute_id, amount_minor, currency, reason, respond_by. The message prints respond_by as a plain date ("21 Oct 2026"); the generic webhook gets the ISO time the driver sent
owner.jobs_stalled pending, failed, stale, waited
alert.<alert id> alert (the id), name (the row's label), title (the alert's headline), detail (the sentence under it), severity and admin_route, the admin route its button opens. On the recovery, alert, name, admin_route and recovered: 1. admin_url is built from admin_route
owner.exchange_rates_stale held (the currencies whose fetched rate was held for a check, each with how far it moved), old (the currencies whose rate is too old, each with its age), missing (the currencies with no rate), source, max_age_days and error (why the last fetch failed, when it did). On the recovery, recovered: 1

Headers

Header Value
Content-Type application/json
X-KahunaCart-Event The event name, so a receiver can route without parsing the body
X-KahunaCart-Event-Alias The name that event went by before it became a row on the screen, on the five that were renamed. Absent on the rest
X-KahunaCart-Signature sha256=<hex>, when the channel has a secret

Record your own events

$kahunacart->notifications()->record($event, $data) queues an event. Every channel the event's row has ticked receives it, along with every config channel subscribed to the name.

PHP
$kahunacart = \Grav\Plugin\KahunaCart\KahunaCart::instance();

$kahunacart->notifications()->record('myplugin.subscription_renewed', [
    'title' => 'A subscription renewed',
    'plan' => 'Pro',
    'seats' => 12,
]);

Register a definition too, so it appears in the list. Without one your event still reaches a config channel subscribed by name, but there is no row for it on the Notifications screen, so nobody can tick a channel for it, switch it off, or set its priority. The code is the event name. See Provider developer guide.

Three things worth knowing:

  • record() never throws. A queue insert that fails because the database blinked is logged, not raised.
  • It is cheap when nothing is listening. An event no channel is ticked or subscribed for is dropped before a row is written, and a store with no channels at all answers from a count without building the registry.
  • $data may be a callable returning the array, for facts that cost something to gather. It runs only once the event is known to be going out — after the channel check and after the dedupe key is claimed — so a count or a query behind a problem message runs as often as the message does rather than as often as the problem does.
  • A third argument is a dedupe key. record($event, $data, 'mykey:42') queues on the first call and is a no-op on every later one. Every problem message needs one.

Four more apply when an add-on brings messages of its own, and the Provider developer guide has the code for each:

  • A new row goes to the store's default channels. Until the owner ticks channels on it, it follows New messages go to.
  • A chatty row can start switched off. Pass enabledByDefault: false when registering it, and it stays off until the owner switches it on.
  • A dashboard alert you already have can push. Give it notify: and core makes the row and sends the message; see Dashboard alerts on your phone.
  • An add-on's own webhook endpoint can report a refusal. notifications()->webhookRejected($source, $sourceName, $reason) sends the same Webhook rejected message a payment provider's refusal does, once per source and reason per day.

Adding variables to your own message template

A notification definition can carry a load_data step: a callable that assembles the extra variables its template needs, run just before the template renders and merged under the variables the sender provided. If a step errors, the failure is logged with the notification's code and the message still goes out without those variables. See Provider developer guide for the signature.

Change it from the API

The screen is drawn from the admin API, and everything it does is available to a script or an assistant. The full reference is under REST API → Notifications.

Route What it does
GET /kahunacart/notifications-registry Every message with its switch, template, audience, the channels it goes to, whether it follows the default, and its priority, plus the channels available to tick and the default
PATCH /kahunacart/notifications-registry Change one message by code: enabled, template, restore, and on a store-owner row only channels, use_default and priority. Sending channels and use_default together is a 422
GET /kahunacart/notification-channels Every channel with its kind and label, and the fields each kind asks for, plus the default. Secrets come back as a fixed mask
PUT /kahunacart/notification-channels/default Choose where new messages go: {channels: [ids]}. An empty list means nowhere, and null forgets the choice
POST /kahunacart/notification-channels Add one: {type, label, settings, use_for_new}. An email channel's addresses can be a string or a list
PATCH /kahunacart/notification-channels/{id} Change one: label, settings, enabled. A secret left at the mask keeps what is stored
DELETE /kahunacart/notification-channels/{id} Remove one, and untick it from every message and from the default
POST /kahunacart/notification-channels/{id}/test Send test. Answers 422 with the service's own words when it does not arrive

Four of these are MCP tools for an assistant: list_notifications, update_notification, list_notification_channels and set_default_notification_channels, which chooses where new messages go from the channel ids. Adding, changing and removing a channel, and Send test, are not tools, on purpose. They mean handling a bot token and putting a real message on somebody's phone, which is the store owner's own doing.

Troubleshoot notifications

Symptom Check
Nothing arrives at all The scheduler. bin/plugin kahunacart status reports a stale queue. Notifications share the queue with the order emails, so if those arrive the worker is fine.
A row says "Nowhere yet" It is switched on, no channel is ticked for it, and there is no default to follow. Click a chip, or choose where new messages go. The store copy is the one row that never says it, because it is not sent through the channels at all.
Send test failed The message under it is the service's own. See the Telegram and ntfy notes above for the common ones. A 401 from Gotify is a wrong token, or a client token where an app token goes. Textbelt gives its own reason: Out of quota means the pack is used up, and a refusal over a link means Textbelt has not allowed links for the key yet, so ask at https://textbelt.com/whitelist?key=YOUR_KEY. A 401 from Twilio is a wrong Account SID or Auth Token. If Twilio says it was sent and the phone gets nothing, the reason is under Monitor → Logs → Messaging in the Twilio console.
One channel is silent, the others work Grav's error log, one line per attempt, naming the channel's label and the reason.
Everything is silent, the log is empty Nothing is ticked, and notifications.enabled is off or no config channel subscribes. Nothing was queued to fail.
Messages arrive with no admin link Admin links need a base URL.