Merchant notifications
Emails go to customers. Notifications go to you: a Slack channel, a Discord server, or an endpoint of your own. This page covers setting up a channel, verifying a signed webhook, and the payload each event carries.
Before you begin
- Put Grav's scheduler in cron. Notifications are queued and delivered by
bin/plugin kahunacart work. See CLI. - Have an incoming-webhook URL from Slack or Discord, or an
http/httpsendpoint of your own. - Set
system.custom_base_urlinuser/config/system.yaml. Without it the messages go out with no admin link. See Admin links need a base URL.
How notifications work
Notifications are queued, never sent inline. Something happens in the store, 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.
Nothing is on until you configure a channel. Channels are read at delivery rather than at recording, so a channel added while jobs are waiting receives them, and a corrected URL applies to the retries.
What each channel type receives
| Type | What it gets |
|---|---|
slack |
A {"text": "…"} post in Slack's mrkdwn dialect. No blocks, no attachments. |
discord |
A content line plus one embed carrying the detail and the admin link. |
webhook |
The full JSON envelope, signed when the channel has a secret. |
A Slack post looks like this:
*Acme Supply: New order #1042*
Customer: Jane Doe · Total: $28.00 · Items: 3 items · Payment: Paid
<https://store.example/admin/plugin/kahunacart#/orders/7|View in admin>
Discord posts empty allowed_mentions on every message, so a customer named @everyone cannot ping a server.
Important
Customer email addresses and postal addresses are never sent to Slack or Discord. Those messages carry the store name, the order number, the customer's name, the amount and the admin link. An order with no billing name shows no name; the email address is not used as a fallback.
The generic webhook does carry the customer's email address, because it is your own endpoint. It does not carry postal addresses. Read those back off the order by order_id.
Add a notification channel
Channels live under notifications.channels in user/config/plugins/kahunacart.yaml, one entry per destination.
notifications:
enabled: true
channels:
# entries go here
- In Slack, open your app, then Incoming Webhooks → Add New Webhook to Workspace.
- Pick the channel and copy the
https://hooks.slack.com/services/…URL. - Add the entry:
- label: 'Sales'
type: slack
url: 'https://hooks.slack.com/services/T00/B00/xxxxxxxx'
enabled: true
events: [order.completed, payment.received]
- In Discord, open Server Settings → Integrations → Webhooks → New Webhook.
- Pick the channel and click Copy Webhook URL.
- Add the entry:
- label: 'Ops'
type: discord
url: 'https://discord.com/api/webhooks/000/yyyyyyyy'
enabled: true
events: all
- Publish an endpoint that accepts a JSON
POSTand answers2xx. - Generate a long random string for the signing secret.
- Add the entry:
- label: 'Ledger'
type: webhook
url: 'https://ops.example.com/kahunacart'
enabled: true
events: all
secret: 'a-long-random-string'
The channel receives its first matching event on the next worker tick.
Warning
A channel that cannot be read — an unknown type, a blank or non-HTTP url — is dropped silently rather than raising an error. Check type and url first when a channel has gone quiet.
A secret on a Slack or Discord channel is discarded rather than sent. Neither service verifies anything, and handing a third party a signing key would leak it.
Verify a webhook signature
Set a secret on a generic-webhook channel and every POST arrives with an HMAC-SHA256 of the raw request body in X-KahunaCart-Signature, in the sha256=<hex> form.
$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. Decoding the JSON and re-encoding it does not reproduce the same string.
Low-stock notifications
stock.low is a sweep run once per worker tick, not a hook on the stock decrement, 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 is exactly a variant that 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 shelf full of amber rows drains over successive ticks.
Delivery and retries
One job is queued per event occurrence and fanned out across matching channels inside the handler.
- A channel that fails does not fail the job. The failure is logged with the channel's label and the reason, and the fan-out carries on.
- Only a fan-out that reached nobody is retried. When every channel fails, the handler throws and the queue's backoff takes over: three attempts, exponentially spaced.
- Any
2xxis a success. Slack answers200, Discord204, and your own endpoint may answer202. - A redirect is not followed and is not a success. A webhook URL that moved is a configuration problem.
Admin links need a base URL
The CLI worker has no HTTP request to read a hostname from, so it cannot build the admin_url deep links on its own. Set system.custom_base_url in user/config/system.yaml:
custom_base_url: 'https://store.example'
Configuration reference
| Key | Default | Effect |
|---|---|---|
notifications.enabled |
true |
Master switch. false stops everything without deleting your channels. |
notifications.low_stock_threshold |
5 |
Stock at or below this earns a stock.low event. 0 means only when it has run out. |
notifications.timeout |
5 |
Seconds allowed per channel per POST. Capped at 15. |
notifications.channels |
empty | The list of channels. |
Per-channel keys:
| Key | Default | Effect |
|---|---|---|
type |
none | slack, discord, 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. |
Events reference
| Event | Fires when |
|---|---|
order.completed |
An order leaves the cart state and becomes a sale |
payment.received |
Funds land for an order that was waiting on them |
order.refunded |
A refund is confirmed by the provider, full or partial |
order.cancelled |
An unpaid order is called off in the admin |
stock.low |
A stock-managed variant crosses the low-stock threshold |
order.refunded waits for confirmation for the same reason the refund email does. An add-on plugin can record events of its own — see Record your own events — and a channel subscribed by name receives them.
Webhook payload reference
Your own endpoint gets the full envelope. This is a contract: receivers key off event, and data gains keys over time but does not lose them.
{
"event": "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,
"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"
}
}
occurred_atis a Unix timestamp taken when the event was recorded, not when it was delivered.- Money is always minor units and always paired with
currency.2800and"USD"is $28.00. See Money. admin_urlis present only when the store has an absolute base URL to build it from.
Per-event data
Everything above is present for all four order events. On top of that:
| Event | Extra keys |
|---|---|
order.completed |
item_count — how many line items the order has |
order.refunded |
refunded_minor — the running total refunded against this order, not this refund's own slice. full — whether it closed the sale out. |
stock.low |
A different set entirely: product_id, product_title, variant_id, variant_title, sku, stock_qty, threshold, admin_url |
Headers
| Header | Value |
|---|---|
Content-Type |
application/json |
X-KahunaCart-Event |
The event name, so a receiver can route without parsing the body |
X-KahunaCart-Signature |
sha256=<hex>, when the channel has a secret |
Record your own events
$kahunacart->notifications()->record($event, $data) queues an event. A channel subscribed to the name receives it.
$kahunacart = \Grav\Plugin\KahunaCart\KahunaCart::instance();
$kahunacart->notifications()->record('myplugin.subscription_renewed', [
'plan' => 'Pro',
'seats' => 12,
]);
An event with no message format of its own still reads sensibly on Slack and Discord: the event name as the title, and up to six scalar values from data as fields. The generic webhook gets exactly what you passed, in the usual envelope.
- It never throws. A queue insert that fails is logged, not raised.
- It is cheap when nothing is listening. An event no channel subscribes to is dropped before a row is written.
- A third argument is a dedupe key.
record($event, $data, 'mykey:42')queues on the first call and is a permanent no-op on every later one. Use it where a code path can legitimately be reached twice.
Troubleshoot notifications
Nothing arrives at all. Run bin/plugin kahunacart status to check the scheduler is draining the queue. Notifications share the queue with the order emails, so if those are arriving the worker is fine.
One channel is silent and the others work. Its failures are in Grav's error log, one line per attempt, naming the channel's label and the reason. A dropped channel logs nothing at all. Re-read its type and url.
Everything is silent and the log is empty. No channel is subscribed to the event, or notifications.enabled is false. Nothing was queued to fail.
Messages arrive with no admin link. Set system.custom_base_url. See Admin links need a base URL.