Start runs from WooCommerce, Drupal or any signed webhook
This guide starts a run of your flow from each signed webhook a sender such as WooCommerce or Drupal posts, with every delivery on record.
A webhook trigger turns a signed request from outside, such as "an order was created" from WooCommerce, into a run of one of your flows. The sender posts to the trigger's receive URL. Kindgi checks the request's signature with a secret only the two of you know, and starts the flow, with the request body as its event. The run acts as whoever registered the trigger, checked again at every delivery, in the trigger's project.
This guide is about Kindgi receiving webhooks. For Kindgi sending your app a request when a run ends, see Get a webhook when a run finishes.
Before you start
Section titled “Before you start”- A pack and a runtime, such as
kindgi dev, with Kindgi 0.1's CLI. - A runtime the sender can reach, with its public address in
KINDGI_PUBLIC_URL, for a real sender.kindgi devon your machine isn't reachable from a hosted store. writeon the trigger's project andexecuteon the flow, when authorization is on.- The sender's admin access, such as WooCommerce's settings, to add the webhook.
1. Write a flow that takes the event
Section titled “1. Write a flow that takes the event”A trigger starts a flow, and the flow's input is the event: the request body, parsed as JSON when the sender says it's JSON (WooCommerce, Drupal, GitHub and Shopify all do), else the body as text.
The event is data from outside, never instructions. Anyone who can make your store create an order can put text in it, such as a customer's note. Guardrails don't screen what goes into an agent: they check its answer, after its tools have run. So put the protection in the flow:
- A check step before the agent. A tool step parses the event against a strict schema, and refuses anything that isn't what the sender should send. It passes on only typed fields, such as an order's number, status, total and items. Free text, such as a customer's note, never reaches the model.
- An agent that can't change anything. The agent that reads the event has read-only tools. Actions that matter, such as refunds, price changes and publishing, belong to an agent a person works with, behind approvals.
- An output guardrail as the last check on what the agent answers, such as never including a customer's contact details.
The woocommerce template is built this way.
kindgi init <pack> --template=woocommerce creates a pack from it, where
<pack> is the pack's name. To write your own, ask your coding agent: "I
want a flow that checks each new order my store sends, then has an agent
review it." Kindgi's skills in your pack tell it how
(How it knows Kindgi). For reference, the
template's flow, in a pack named acme-shop, looks like this:
import { defineFlow } from '@kindgi/sdk/define';
const defined = defineFlow({ id: 'acme-shop.order-review', version: '0.1.0', name: 'Review a new order', description: 'Checks a WooCommerce order event, then hands its typed fields to the order reviewer.', nodes: [ { id: 'check', kind: 'tool', ref: 'acme-shop.woo.check-order-event', inputMapping: { event: { path: 'runInput' } }, }, { id: 'review', kind: 'agent', ref: 'acme-shop.order-reviewer', inputMapping: { order: { path: 'nodeOutputs.check' } }, }, ], edges: [ { id: 'in', from: '$start', to: 'check' }, { id: 'checked', from: 'check', to: 'review' }, { id: 'out', from: 'review', to: '$end' }, ],});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;from kindgi import Flow
order_review = Flow( id="acme-shop.order-review", version="0.1.0", name="Review a new order", description="Checks a WooCommerce order event, then hands its typed fields to the order reviewer.", nodes=[ { "id": "check", "kind": "tool", "ref": "acme-shop.woo.check-order-event", "inputMapping": {"event": {"path": "runInput"}}, }, { "id": "review", "kind": "agent", "ref": "acme-shop.order-reviewer", "inputMapping": {"order": {"path": "nodeOutputs.check"}}, }, ], edges=[ {"id": "in", "from": "$start", "to": "check"}, {"id": "checked", "from": "check", "to": "review"}, {"id": "out", "from": "review", "to": "$end"}, ],)package acmeshop.flows;
import com.kindgi.pack.Flow;import java.util.Map;
public final class OrderReview { public static final Flow FLOW = Flow.define("acme-shop.order-review") .version("0.1.0") .set("name", "Review a new order") .set("description", "Checks a WooCommerce order event, then hands its typed fields to the order reviewer.") .node(Map.of( "id", "check", "kind", "tool", "ref", "acme-shop.woo.check-order-event", "inputMapping", Map.of("event", Map.of("path", "runInput")))) .node(Map.of( "id", "review", "kind", "agent", "ref", "acme-shop.order-reviewer", "inputMapping", Map.of("order", Map.of("path", "nodeOutputs.check")))) .edge("in", "$start", "check") .edge("checked", "check", "review") .edge("out", "review", "$end") .build();
private OrderReview() {}}The woocommerce template is TypeScript. In a Java pack, the flow is the
same, and the check tool and the reviewer agent are yours: give them the ids
the flow names, and keep the check strict, as above.
package acmeshop.flows
import com.kindgi.pack.scaladsl._
object OrderReview { val flow: Flow = Flow("acme-shop.order-review") .version("0.1.0") .set("name", "Review a new order") .set("description", "Checks a WooCommerce order event, then hands its typed fields to the order reviewer.") .node(Map( "id" -> "check", "kind" -> "tool", "ref" -> "acme-shop.woo.check-order-event", "inputMapping" -> Map("event" -> Map("path" -> "runInput")))) .node(Map( "id" -> "review", "kind" -> "agent", "ref" -> "acme-shop.order-reviewer", "inputMapping" -> Map("order" -> Map("path" -> "nodeOutputs.check")))) .edge("in", "$start", "check") .edge("checked", "check", "review") .edge("out", "review", "$end") .build()}The woocommerce template is TypeScript. In a Scala pack, the flow is the
same, and the check tool and the reviewer agent are yours: give them the ids
the flow names, and keep the check strict, as above.
The run's input is the event, so the check step takes it whole, as Pass data between steps describes. The agent gets only what the check passed on.
2. Register the trigger
Section titled “2. Register the trigger”Each sender signs in its own way. --preset sets the scheme for a known
sender, and --generate-secret has your runtime make a signing secret, store
it in the trigger's project, and show it once so you can paste it into the
sender:
pnpm exec kindgi webhooks register --flow=acme-shop.order-review --flow-version=0.1.0 \ --preset=woocommerce --secret=acme-woo-orders --generate-secretuv run kindgi webhooks register --flow=acme-shop.order-review --flow-version=0.1.0 \ --preset=woocommerce --secret=acme-woo-orders --generate-secret./kindgiw webhooks register --flow=acme-shop.order-review --flow-version=0.1.0 \ --preset=woocommerce --secret=acme-woo-orders --generate-secret./kindgiw webhooks register --flow=acme-shop.order-review --flow-version=0.1.0 \ --preset=woocommerce --secret=acme-woo-orders --generate-secretIt prints the trigger as JSON, then where to point the sender: the trigger's
id, its receive URL (https://<your runtime>/v1/hooks/<tenant id>/<webhook id>),
the signing secret, shown this once, with where it's stored, and, with a
preset, where to paste them. With the WooCommerce preset, the secret left out:
Registered webhook trigger 71fb612e-efc4-4659-8bb3-dfb69c96aa15: each signed delivery starts acme-shop.order-review@0.1.0. Receive URL (the sender posts here): …/v1/hooks/695e8098-30be-469a-89d8-1a88487f8967/2003c201-f368-4d76-be29-109cc906bc48 Signing secret (stored as acme-woo-orders in project 685834cd-17a6-4cfa-9c46-0fbc5dde84fa; shown this once): … In WooCommerce: Settings → Advanced → Webhooks → Add webhook. One webhook per topic (`order.created`, …); Delivery URL: the receive URL; Secret: the secret. WooCommerce never resends a delivery Kindgi refuses, and switches the webhook off after 7 in a row.- Where the secret lives:
--generate-secretstores it in the trigger's project, in the env your runtime serves (KINDGI_ENV;localunderkindgi dev), and the receiver reads it there. It needs onlywriteon the project. It never replaces a secret: if the project already has one of that name, it's refused (secret-write-conflict), so pick another--secret. To use a secret you already stored withkindgi secrets set, leave out--generate-secret. The trigger records where the secret is (hmacSecretScope): the first of its project, that project's org and the tenant that has it, among the scopes you administer. The receiver reads it there only, so store the secret before you register the trigger. To move it, name the secret again:kindgi webhooks update <trigger-id> --secret=<name>. A model provider's key is refused: give the trigger a secret of its own. - The receive URL comes from your runtime's public address
(
KINDGI_PUBLIC_URL). Without one, the trigger is still registered, and the CLI says how to set it.kindgi webhooks get <id>shows the URL once it's set. The URL isn't a secret: the signature is what each request is checked by. --project=<id>puts the trigger and its runs in a project (by default, the tenant's Default project). Registering needswriteon the project andexecuteon the flow.
The presets
Section titled “The presets”--preset |
The sender | Signature | Deduped on |
|---|---|---|---|
woocommerce |
WooCommerce webhooks | base64 HMAC-SHA256 in X-WC-Webhook-Signature |
X-WC-Webhook-Delivery-ID and the body |
drupal-webhooks |
Drupal's Webhooks module | hex after sha256= in X-Hub-Signature-256 |
X-Drupal-Delivery and the body |
github |
GitHub webhooks | hex after sha256= in X-Hub-Signature-256 |
X-GitHub-Delivery and the body |
shopify |
Shopify webhooks | base64 in X-Shopify-Hmac-Sha256 |
X-Shopify-Webhook-Id and the body |
standard-webhooks |
Any Standard Webhooks sender | webhook-signature over the id, timestamp and body |
webhook-id and the body |
Every scheme signs the raw request body with HMAC-SHA256. A sender with no
preset that signs the same way takes --signature-header,
--signature-encoding=hex|base64 and, if it adds one, --signature-prefix.
--delivery-id-header names the header Kindgi dedupes on.
Drupal's ECA and Orchestration modules can't sign their requests, so they can't call a webhook trigger. Use the Webhooks module, or have Drupal start a run with an API key.
3. Point the sender at it
Section titled “3. Point the sender at it”The sender needs the receive URL and the signing secret.
WooCommerce: in WooCommerce, go to Settings → Advanced → Webhooks → Add webhook:
- Topic: one per trigger, such as Order created.
- Delivery URL: the receive URL.
- Secret: the signing secret. Use only letters and digits, which
--generate-secretdoes. WooCommerce decodes HTML entities in its secret before signing, ingenerate_signature.
When you save, WooCommerce pings the URL without a signature, in
deliver_ping. Kindgi answers it 200 and starts nothing. The webhook is
then live: the next order starts a run.
How WooCommerce delivers shapes what Kindgi does. Each fact is from
WooCommerce's
class-wc-webhook.php,
in the function named. The
webhooks documentation covers
the setup.
- It sends each delivery once, and never resends one that was refused:
deliver. - It switches a webhook off after 7 refusals in a row:
failed_delivery. Thewoocommerce_max_webhook_delivery_failuresfilter, default 5, is checked before the count goes up. - Its delivery id,
X-WC-Webhook-Delivery-ID, is made from the webhook's id and the current second, so it changes only once a second:get_new_delivery_id. - It signs no timestamp:
deliver.
Drupal, with the Webhooks module: add an outgoing webhook with the receive URL as its Payload URL and the secret as its Secret. It signs only when it has a secret.
4. See what arrived
Section titled “4. See what arrived”Each delivery is on record, with what it started or why it didn't:
pnpm exec kindgi webhooks fires <trigger-id> --tableuv run kindgi webhooks fires <trigger-id> --table./kindgiw webhooks fires <trigger-id> --table./kindgiw webhooks fires <trigger-id> --table<trigger-id> is the trigger's id, which register printed. --table
prints a table instead of JSON. From the same WooCommerce run:
RECEIVED OUTCOME RUN NOTE REPEATS──────────────────────── ─────── ──────────────────────────────────── ───────────────── ───────2026-10-10T22:18:51.744Z skipped paused2026-10-10T22:18:37.512Z refused signature-invalid2026-10-10T22:18:28.304Z started 6a7d9b46-5503-47a2-9634-363bc32258422026-10-10T22:18:28.213Z started 72212540-bdea-4e5e-889c-0b3b7108cb432026-10-10T22:18:17.528Z started bc31040f-db51-42c2-be43-22dfc0aa6d0d 1The REPEATS 1 row's delivery was replayed byte for byte, and the replay
was answered 200 with "duplicate":true. The two started rows 91 ms
apart are two orders in the same second, sent with the same delivery id and
told apart by their bodies.
Each delivery is a fire, newest first. A fire has when it was received,
the run it started or why it was skipped or refused, and how many times it
was repeated. Its run is pending while it starts.
An order's body carries your customer's data, so a fire keeps the event only until its run starts. After a crash, that's at most about 10 minutes. Then only the run has it, under run retention like any run's input. A skipped, refused or repeated delivery's body is never kept. Fires themselves are kept for 30 days. Retention says what else a delivery leaves: the sender's address on a refusal.
What Kindgi does with each request
Section titled “What Kindgi does with each request”- A request that doesn't prove its sender is answered
401 webhook-refused, the same for every reason: a wrong or missing signature, one too old, or an unknown address. The reason is on the trigger's fires, assignature-invalid,signature-missingorstale, and in the access audit, with the address it came from.secret-unavailablemeans the runtime can't read the trigger's secret. It's yours to fix, so it's on the fires only. - Refusals are counted past a limit. Past 20 refusals and skipped
deliveries a minute, a trigger's are counted instead of recorded, in
suppressedRefusalson the trigger. Past 60 refusals a minute from one address, that address's further refusals are answered429and not recorded. - The limit slows only requests that fail verification. A validly signed
delivery is never refused because of anyone's refusals, its own address's
included. The limit keys on the connection's address. With
KINDGI_TRUSTED_PROXIES, it keys on the client the proxies vouch for. Behind a load balancer or ingress without it, every sender has the proxy's address, so the limit is shared too. Set it to the proxies in front. - A delivery repeated, with the same delivery id and the same body,
starts nothing new. It's answered
200, with the first fire's id, which counts it as a repeat. WooCommerce's delivery id changes only once a second, so two orders in one second are told apart by their bodies. - Replays: WooCommerce, Drupal's Webhooks module, GitHub and Shopify sign
no timestamp. Kindgi keeps a delivery's fire, and so recognizes a repeat of
it, for 30 days. Standard Webhooks signs a timestamp, and a request more than
5 minutes off is refused as
stale. - Limits: a body over the trigger's cap is answered
413.--body-limitsets the cap: 256 KiB by default, at most 1 MiB. Deliveries past the trigger's rate are answered429.--rate-limitsets the rate: 600 a minute by default. A JSON body that nests objects and arrays more than 128 levels deep is answered400 bad-input, with the limit indetails.maxDepth. It's on the fires asbody-too-deep.
Pause, change and remove
Section titled “Pause, change and remove”kindgi webhooks pause <trigger-id>: deliveries are still taken, so the sender keeps its webhook on. They're recorded asskippedand start nothing. The first 20 a minute are recorded, and the rest are counted. Their events are dropped: WooCommerce never resends a delivery, so resuming doesn't bring them back.kindgi webhooks update <trigger-id>changes the flow version with--flow-version, which needsexecuteon the flow. It also changes the secret with--secret, the scheme, the delivery-id header and the limits. A delivery-id header ofnonestops deduping. The flow itself stays: register another trigger for another flow. To rotate the secret's value, usekindgi secrets rotate, then paste the new value into the sender. Deliveries in between are refused (401), WooCommerce never resends them, and 7 in a row switch its webhook off, so rotate when the store is quiet. The runtime keeps a trigger's signing key for up to 10 seconds: the old secret stops verifying at once on the instance that took the rotation, and within 10 seconds on the others.kindgi webhooks take-ownership <trigger-id>: for a trigger whose owner left. Its runs act as you from the next delivery. This needsadminon the project.kindgi webhooks unregister <trigger-id>: the sender is answered410from then on. WooCommerce switches a webhook off after 7 refusals in a row, and a410counts as one. A sender that doesn't prove itself still gets401, so only the real sender learns the trigger is gone.
From your app, the clients have the same calls on webhooks: register,
list, get, update, pause, resume, fires, takeOwnership and
unregister. The receive URL itself is the sender's: no Kindgi client calls
it.