Outbound webhooks
Outbound webhooks tell another system when something happens in the store. A subscription is a topic and a URL. Every occurrence of the topic is signed, queued and delivered to the URL with retries, and every attempt is logged. This page is the receiver's contract and the merchant's operating manual.
This is different from the inbound webhooks payment providers send to the store (Payment providers → The webhook delivery log) and from merchant notifications, the Slack, Discord and generic channels that tell a person rather than a system.
Before you begin
- The scheduler running. Every delivery goes through the job queue.
- An endpoint that answers a
2xxquickly and can verify an HMAC-SHA256 signature.
Subscriptions
Webhooks in the admin's Operations band lists the subscriptions and the delivery log. A subscription has a name, a topic, a target URL and a status.
The topic is one of the named topics, any event name, or a family:
| Topic | Fires on |
|---|---|
order.completed |
an order completing checkout |
order.paid |
an order's payment being confirmed |
order.refunded |
a refund, full or partial |
order.cancelled |
an order being cancelled |
order.shipped |
a shipment being recorded |
order.changed |
a staged order change being confirmed |
product.saved |
a product being created or updated |
product.deleted |
a product being deleted |
product.stock_adjusted |
a stock level changing |
customer.erased |
a customer being erased |
customer.orders_reassigned |
a customer's orders being moved to another |
coupon.redeemed |
a coupon being used on a completed order |
event.onKahunaCart… |
any of the plugin's events, by its full name, such as event.onKahunaCartOrderPaid |
order.*, product.*, customer.*, coupon.*, event.* |
every topic in a family |
The full topic list is on the Webhooks screen and in bin/plugin kahunacart webhooks:subscriptions --topics. A topic not on it is refused on save, with the nearest matches suggested.
The target must be https:// except on localhost, 127.0.0.1 and .test or .local hosts.
The secret is shown once
Saving a subscription mints a signing secret and shows it once; it is stored but never shown again. Rotate secret mints a new one, shows it once, and the old one stops working immediately. Copy it into the receiving system before closing the panel.
Ping, and the active status
A subscription starts paused. Activating it sends a ping delivery, and the subscription becomes active only when the target answers 2xx. A target that does not answer stays paused, and the reason is in the delivery log.
Ping on an active subscription sends another one whenever you like. The doctor pings every active subscription unless run with --offline. See Going live → Webhook reachability.
Auto-disable
Five consecutive failed deliveries disable a subscription. A disabled subscription receives nothing until a person activates it again, which pings it first. The threshold is webhooks.disable_after. A success at any point resets the count.
Delivery
One occurrence is one job in the job queue, delivered by the worker on its next tick. The job tries every active subscription whose topic covers the occurrence. A subscription already delivered to is not tried again on a retry.
A delivery not answered 2xx within webhooks.timeout seconds fails. The job retries with exponential backoff up to webhooks.max_attempts times in total, and each attempt is its own row in the delivery log.
The request
POST <target_url>
Content-Type: application/json
X-KahunaCart-Topic: order.paid
X-KahunaCart-Event: onKahunaCartOrderPaid
X-KahunaCart-Delivery: order.paid:1042:1756742400:9f3a1c2b
X-KahunaCart-Subscription: 3
X-KahunaCart-Signature: sha256=<hmac>
{
"api_version": "1",
"topic": "order.paid",
"event_id": "order.paid:1042:1756742400:9f3a1c2b",
"occurred_at": 1756742400,
"store": { "name": "Example Store", "url": "https://example.com", "currency": "EUR" },
"data": { "order": { "id": 1042, "number": "1042", "status": "completed", "payment_status": "paid", "total_minor": 4500, "currency": "EUR", "email": "…" } }
}
event_id is the same on every attempt at one occurrence and on a redelivery, so the receiver can use it as an idempotency key. X-KahunaCart-Delivery carries the same value. api_version is the payload contract's version, stored per subscription, so a later contract does not change what an existing subscription receives.
Verify the signature
The signature is an HMAC-SHA256 of the raw request body with the subscription's secret, hex encoded, sent in X-KahunaCart-Signature as sha256=<hex>. Merchant notifications use the same header and scheme.
Compute it over the bytes as they arrived, before any JSON decoding, and compare in constant time:
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $request->getHeaderLine('X-KahunaCart-Signature'))) {
http_response_code(401);
exit;
}
Answer 2xx quickly and do the work afterwards. A receiver that takes longer than webhooks.timeout seconds is a failed delivery; the retry carries the same event_id.
What is in data
An order carries its own fields plus items, the same order data every merchant notification carries through OrderData. It holds no address block, no notes and no payment references. A product carries its row, a customer its id and email, and a coupon its id and code with the order it was used on.
An event.* topic carries the event's scalar arguments and, for every array argument, that argument's id. Whole objects never leave the store.
The delivery log
One row per attempt: the subscription, the topic, the event id, the order, the attempt number, the request size, the response status, a 2 KB excerpt of the response body, how long it took, and any error. Rows are kept for thirty days under webhooks.retention_days, the same as the inbound webhook log, and the worker removes expired rows from both logs on each tick.
Redeliver on a row whose occurrence is still in the queue's history queues a fresh delivery to that one subscription with the same event_id, as a new row. A row whose job has been swept cannot be redelivered, and the button is not offered.
An open order shows a Webhook deliveries panel beside it: the kahunacart.webhook-deliveries widget in the order.details.side zone. It lists that order's last ten attempts and links to the full log, and draws nothing on a store with no subscriptions. bin/plugin kahunacart webhooks:subscriptions --deliveries prints the same log.
From a shell
bin/plugin kahunacart webhooks:subscriptions # list, with counts
bin/plugin kahunacart webhooks:subscriptions --topics # every topic a subscription may name
bin/plugin kahunacart webhooks:subscriptions --create --name=ERP --topic=order.paid --url=https://erp.example.com/hook
bin/plugin kahunacart webhooks:subscriptions --ping=3 # ping, and activate on 2xx
bin/plugin kahunacart webhooks:subscriptions --pause=3
bin/plugin kahunacart webhooks:subscriptions --delete=3
bin/plugin kahunacart webhooks:subscriptions --deliveries # the log; --deliveries=3 for one subscription
bin/plugin kahunacart webhooks:subscriptions --redeliver=812 # a delivery row's id
bin/plugin kahunacart webhooks:subscriptions --purge # sweep rows older than webhooks.retention_days now
--create prints the secret once, like the admin. See CLI → webhooks:subscriptions.
Configuration
| Key | Default | Effect |
|---|---|---|
webhooks.max_attempts |
5 |
How many times one occurrence is tried before its job is failed. Each attempt is a delivery row |
webhooks.disable_after |
5 |
Consecutive failed deliveries that disable a subscription |
webhooks.retention_days |
30 |
How long delivery rows are kept |
webhooks.timeout |
5 |
Seconds a receiver has to answer, 1 to 15 |
Privacy
A subscription exports order data to somebody else's server on every order. Every payload uses the same field restrictions as merchant notifications, so it contains no address, note or payment reference. Every order topic includes the customer's email address.
The delivery log keeps the receiver's response, not the request body. Erasing a customer erases the order rows the log points at; the log rows carry an order id and nothing personal.