Documentation
Webhooks
Kicklace posts to an address of yours when something happens.
Outgoing webhooks are a feed: everything that happens to a record, posted to your own code or to any service that takes a webhook, like Zapier, Make or n8n. Every post is signed.
Outgoing webhooks
Connections → Outgoing webhooks → Add an address opens a small wizard: where to post, and what it should hear, then the signing secret shown once. Everything Kicklace hears about a record comes through here, so your own code sees exactly what Kicklace sees.
Up to ten addresses per workspace, owners and admins.
What it can hear
| Trigger | When |
|---|---|
stage_entered |
somebody moved to a lifecycle or pipeline stage |
subscribed |
somebody joined a list |
unsubscribed |
somebody came off one |
event |
any event landed: a page read, a signup, a purchase, a name of your own |
email_in |
somebody wrote back |
field_changed |
a field on a record changed |
subscription.updated |
a subscription changed: a new one, a renewal, a plan or a seat count |
release.created |
a deployment became a release |
organization.created |
a new organization |
organization.updated |
an organization changed: renamed, archived, or whose Stripe customer it is |
population.entered |
somebody entered a population |
population.left |
somebody left one |
A reply is also an event, so an address that asked for event hears it too. The type on the body
is always the most specific of the names that apply, so you can switch on one field.
The last six are the graph's own entities: data on one of them carries the same JSON
the read API answers for that record (Reading your data), as data.subscription,
data.release or data.organization, rather than the small, trigger-specific facts the first six
carry. A
subscription and a release fire from the live receiver only; a history read fires nothing, the same
way nothing else about a history read does.
The body
{
"id": "whd_a1b2c3d4e5f6",
"type": "stage_entered",
"at": "2026-09-09T09:00:00.000Z",
"workspace": { "id": "ws_…", "slug": "northshelf" },
"record": {
"id": "rec_…",
"kind": "person",
"name": "Ada Reyes",
"email": "ada@example.com",
"stage": "purchaser"
},
"data": { "stage": "purchaser", "object": "people" }
}
record is who it happened to. data is the trigger's own facts and nothing else: the stage and
object for a stage move, the event's name for an event, the list for a subscription, the field for a
change. It's small on purpose: the point is a hundred lines of your own code, not a second API. When
you want the whole person, take the id and ask.
Three headers
X-Kicklace-Delivery: whd_a1b2c3d4e5f6
X-Kicklace-Event: stage_entered
X-Kicklace-Signature: sha256=<hex>
The signature is an HMAC-SHA256 of the exact bytes of the body, keyed with that webhook's own secret, which is shown once when you add the address. Check it the way Kicklace checks Stripe's: recompute, compare in constant time, refuse anything else.
import { createHmac, timingSafeEqual } from "node:crypto";
const body = await req.text(); // the raw body, before JSON.parse
const want = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
const got = req.headers.get("x-kicklace-signature") ?? "";
const ok =
want.length === got.length &&
timingSafeEqual(Buffer.from(want), Buffer.from(got));
A retry sends the same bytes, so the signature still holds.
Retries
One attempt gets ten seconds. A non-2xx, a network failure, or a redirect schedules another go. A
3xx is a failure here on purpose, because a webhook that silently follows a redirect can be pointed
somewhere else.
The waits are 1, 5, 30, 120 and 720 minutes: six attempts in all, spread over about fifteen hours, which covers a deploy, a certificate that expired overnight, and a rate limit. After that the delivery is marked failed and the screen says so, rather than knocking for ever.
Each delivery is claimed before it is sent, so the immediate attempt and a later retry can never
both go out. The log on that screen shows the last twenty with what happened to each and a Retry
button, and Send a test posts a real body with type: "test" so you can wire up your receiver
before anything real has happened.
The address has to be a public https one
Checked when you save it, not when it is used: https only, and never localhost, a private range,
a link-local address, the cloud metadata address, a carrier-grade NAT range, a .local or
.internal name, or a bare hostname. An address typed into a form is otherwise a way to make
Kicklace reach servers that are not yours.
Where you see all of it
Your website's heartbeat on Connections → Your website prints posts arriving: the action, what happened to it, the origin and the minute, and never a body or an address. The delivery log on Connections, and read-only on Connections → Your servers, prints the last posts going out, with the answer each one got and a Retry. A delivery keeps the body it sent, because that is what a retry sends again; deliveries are deleted after thirty days.