Appearance
Webhooks
Outgoing webhooks to Zapier, Make, n8n or any URL: published posts, comments, orders, bookings.
kitloom/webhooks · admins: kitloom/webhooks-filament, kitloom/webhooks-nova
Outgoing webhooks: when a post is published, a comment posted, an order placed or a booking made, the site POSTs a signed JSON body to the addresses you set up — Zapier, Make, n8n, a Slack workflow, a receiver of your own. Calls go out from the queue, are retried, and every one is in a log. The admin is kitloom/webhooks-filament or kitloom/webhooks-nova.
Setting up a webhook
- In the receiving service, create a webhook trigger ("Catch Hook" in Zapier, "Custom webhook" in Make, a Webhook node in n8n) and copy the URL it gives you.
- Webhooks → Add: a name, that URL, the topics you want, and keep the secret the form generated (or paste the receiver's, when it gives one).
- Save, then "Send test": the receiver gets a
pingand the admin says what it answered. Deliveries show every call since, with the body and the answer.
The URL must be a public address on the internet. The site refuses a webhook to its own server or network — localhost, 127.0.0.1, the private ranges (10.…, 192.168.…), the cloud's metadata address 169.254.169.254 — and a host that does not resolve: the receiver's answer is shown in the log, so such a webhook would let whoever sets it up read what the server sees inside its network. The address is checked when the webhook is saved and again before every call, since the name may point elsewhere by then; a refused call fails the delivery for good. A redirect is not followed: a receiver that answers 3xx has not taken the delivery — use the address it redirects to. A receiver on the same server or network (a self-hosted n8n) needs WEBHOOKS_ALLOW_PRIVATE_HOSTS=true in the server's environment; it cannot be turned on from the admin.
A webhook sends while it is active. Disabled is yours: nothing is sent, the webhook stays. Paused is the module's: the deliveries failed too many times in a row (Settings → Pause after failures, 5 unless set) — the receiver is down or the URL is wrong — and nothing more is sent until you resume it ("Resume" in the list, or save the webhook again). A test delivery goes out whatever the status.
Topics
| Topic | When | The body's data |
|---|---|---|
post.published | A post, page or record of another type went live — created published, or published from a draft, a pending or a scheduled one | the post |
post.updated | A published post was saved again (not the save that published it, not a draft) | the post as it is now |
post.trashed | A post went to the trash | the post as it was |
post.deleted | A post is gone for good | id, type, slug, title |
comment.created | A comment or product review was posted, whatever status it got | the comment, with status |
comment.approved | A waiting, spam or trashed comment was approved | the comment |
order.created | An order was placed at the checkout (it may still wait for the gateway) — with kitloom/shop | the order |
order.updated | An order moved to another status — paid, completed, cancelled, refunded, on hold | the order, with previous_status |
order.refunded | Money went back on an order, all of it or a part | the order, with refund |
customer.created | A customer made an account in the shop | id, email, name |
booking.created | A booking was made — on the site, in the admin, by an agent — with kitloom/bookings | the booking |
booking.updated | A booking was confirmed, declined, cancelled, completed or marked a no-show | the booking, with previous_status |
ping | A test sent by hand; every webhook gets it, it is not subscribed to | message, webhook |
The post topics send only posts of the types the site shows to visitors — public types: posts, pages, products, the types of the active theme and of Post Types. What a module keeps in the posts table for itself is never sent: a contact form's messages (with the sender's name, address and words), orders, coupons, menu items, revisions, and any type the site does not declare public. ignore_types leaves out public types too; a shop's own rows in the comments table (ignore_comment_types) are no comments and never sent, and neither is a comment on a record of a type that is not public. A post is post.published once, even when the save that published it is followed by another in the same request.
The post's body: id, type, status, slug, title, excerpt and content as written (the receiver renders or strips the HTML as it likes), link, author (id, name), parent_id, date and modified (UTC), terms under each taxonomy (id, name, slug), featured_image (id, and url when kitloom/media is installed). The comment's: id, post_id, post_title, type, status (approved, pending, spam, trash), author_name, author_email, author_url, user_id, content, parent_id, date. An order is what the shop shows the customer on the "order received" page (number, status, email, customer, items, totals, billing, shipping, …) with its id; a booking is what the booking module tells everyone (code, status, service, staff, start, end, name, email, …) with its id and note.
What the receiver gets
A POST with Content-Type: application/json and these headers:
| Header | Value |
|---|---|
X-Kitloom-Topic | the topic, post.published |
X-Kitloom-Delivery | the delivery's id, a UUID — the same as id in the body; a retry carries the same one |
X-Kitloom-Webhook | the webhook's id |
X-Kitloom-Signature | HMAC-SHA256 of the raw body with the webhook's secret, hex |
The body:
json
{
"id": "6f1c0c6e-2a3b-4a0d-9d2e-5b1f3b7a0c11",
"topic": "post.published",
"created_at": "2026-10-18T12:00:00Z",
"site": "https://example.com",
"actor": {"id": 1, "name": "Ada"},
"data": {"id": 42, "type": "post", "status": "publish", "slug": "hello", "title": "Hello", "…": "…"}
}actor is who did it — the event's user, or whoever was signed in — and null for a visitor or the system. A receiver that must trust the call checks the signature over the body exactly as received, before parsing it:
php
$expected = hash_hmac('sha256', $request->getContent(), $secret);
abort_unless(hash_equals($expected, $request->header('X-Kitloom-Signature', '')), 403);Zapier, Make and n8n accept the call as it is; the headers are there for a receiver of your own. Any 2xx answer is a success. Anything else, a timeout or no connection is a failed attempt: the call is tried again after a growing wait (3 attempts unless configured) and the delivery is then failed for good. The receiver's answer — the code and the first 2000 characters of the body — is kept with the delivery, and "Send again" makes a new delivery of the same body.
Where things are kept
| What | Where |
|---|---|
| Webhooks | table webhooks (the site's table prefix in front): name, URL, secret, topics (JSON), status, failures in a row, last answer — model Kitloom\Webhooks\Models\Webhook, key webhook in the ModelRegistry |
| Deliveries | table webhook_deliveries: the body as sent, attempts, the answer, the error, how long it took — model Kitloom\Webhooks\Models\WebhookDelivery, key webhook_delivery |
| Settings | options webhooks_retention_days (30 unless set; 0 keeps every delivery) and webhooks_failure_limit (5; 0 never pauses) |
| The calls | spatie/laravel-webhook-server's job on the queue (kitloom-webhooks.queue, connection) |
The secret is kept as it is, as WooCommerce keeps its own: the owner reads it again to paste it into the receiver. Whoever may manage webhooks (manage_webhooks, administrators) reads it.
webhooks:prune deletes deliveries older than the retention (--days for one run); the module schedules it daily — the application's schedule:run has to run. It runs on one server of several, by a lock in the cache, so a site on several servers needs a shared cache store.
Without a queue worker (QUEUE_CONNECTION=sync) a call is made while the page that caused it waits — up to the timeout, 5 seconds unless configured — and gets one attempt: there is nobody to retry it. A site with webhooks wants a worker.
From code
php
use Kitloom\Webhooks\Webhooks;
$webhooks = app(Webhooks::class);
$webhook = $webhooks->create(['name' => 'Zapier', 'url' => 'https://hooks.zapier.com/…', 'topics' => ['post.published']], $actor);
$webhooks->ping($webhook, $actor); // a test delivery
$webhooks->resume($webhook, $actor); // after a pause
$webhooks->resend($delivery, $actor); // the same body againWith an actor the service refuses what they may not do; without one it is the system.
A topic of your own — a module's or the application's: a Kitloom\Webhooks\Contracts\WebhookTopic (its name, label, group, the events it listens to, whether an event counts, and the data of the body) tagged WebhookTopic::TAG in a provider's register(). It is then on the form, in the agents' description of the site, and its events are listened to; one named like a built-in topic replaces it.
php
$this->app->tag([ReviewPublished::class], WebhookTopic::TAG);AI agents
With kitloom/ai installed, an agent whose user has manage_webhooks lists, creates and changes webhooks (webhooks_list, webhooks_create — which answers the secret once — webhooks_update, also with ping: true for a test delivery) and reads the log (webhooks_deliveries); describe_site tells it the topics and how a delivery is signed. Nothing is deleted: a webhook is disabled instead. kitloom-webhooks.ai.register false — no webhook tools.
Personal data
A delivery keeps a copy of what went out, and an event about a comment, an order or a customer carries a person's email address — for the retention days (30 unless set). With kitloom/privacy installed, Tools → Export Personal Data lists the deliveries with the address in the body sent or in the receiver's answer: the event, the receiving service, when, how it went. Erase Personal Data deletes them from the log. A delivery still in the queue stays until it ends — the call goes out from the queue's copy anyway — and the report counts it as kept; erase again once it has. The services the webhooks sent to keep their own copies, and the report says to ask them. kitloom-webhooks.privacy.register false leaves the log out of both.
Configuration
config/kitloom-webhooks.php (php artisan vendor:publish --tag=kitloom-webhooks-config):
queue,connection— where the calls are queued; the application's defaults unless set.tries(3),timeout(5 seconds),verify_ssl(true) — how a call is made. On the sync driver a call gets one attempt whatevertriessays.allow_private_hosts(false,WEBHOOKS_ALLOW_PRIVATE_HOSTS) — receivers on the server's own network are allowed too.ignore_types,ignore_comment_types— post and comment types never sent; for posts on top of the rule that only public types are.schedule— the daily pruning.ai.register,privacy.register,filament.register,nova.register.
Tests
From the application that installs the package: php vendor/bin/phpunit --testsuite Webhooks.