Skip to content

Powered by Grav

Coming soon — KahunaCart is in final testing. Join the list and be first to know. Join the list

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/https endpoint of your own.
  • Set system.custom_base_url in user/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:

TXT
*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.

YAML
notifications:
  enabled: true
  channels:
    # entries go here
  1. In Slack, open your app, then Incoming Webhooks → Add New Webhook to Workspace.
  2. Pick the channel and copy the https://hooks.slack.com/services/… URL.
  3. Add the entry:
YAML
    - label: 'Sales'
      type: slack
      url: 'https://hooks.slack.com/services/T00/B00/xxxxxxxx'
      enabled: true
      events: [order.completed, payment.received]

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.

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. 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 2xx is a success. Slack answers 200, Discord 204, and your own endpoint may answer 202.
  • A redirect is not followed and is not a success. A webhook URL that moved is a configuration problem.

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:

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.

JSON
{
  "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_at is a Unix timestamp taken when the event was recorded, not when it was delivered.
  • Money is always minor units and always 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

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.

PHP
$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.