Documentation
Webhooks
Kicklace posts to addresses of yours when something happens to a record, signed so you can prove it came from here, and retried when your server is having a bad afternoon. It takes posts the other way too: an automation can have an address of its own that your server starts it from. This is the body, the headers, the signature and the retries.
Three doors, for three different jobs.
| The door | What it is for |
|---|---|
| Outgoing webhooks | a feed: everything that happens to a record, to your own code, or to any service that takes a webhook |
The http_request action |
one automation step: "when this happens to this person, tell that server" |
| An automation's own address | the other direction — your server posts, and one automation starts |
The first two go out and are signed. The third comes in, and the address itself is the credential.
Outgoing webhooks
Add an address under Settings → Integrations → Outgoing webhooks, choose what it should hear, and it hears it. Every trigger Kicklace fires comes through here, whether or not an automation happens to be listening for the same thing, so your own code sees exactly what the automations see.
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 |
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 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 is deliberately small — the point of it 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 — a 3xx is a failure
here, on purpose, because a webhook that silently follows a redirect is a webhook that can be
pointed somewhere else — schedules another go.
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 knock on doors that are not yours.
An automation posting to your server
The http_request step lets an automation post to an address of yours — "when somebody cancels, tell
our billing service" — in the same body and under the same signature scheme as an outgoing
webhook, so one receiver reads both. The event name is automation, and data carries which
automation it was and what started it:
{
"id": "whd_…",
"type": "automation",
"at": "2026-09-09T09:00:00.000Z",
"workspace": { "id": "ws_…", "slug": "northshelf" },
"record": { "id": "rec_…", "kind": "person", "name": "Ada Reyes", "email": "ada@example.com" },
"data": {
"automation": { "number": 4, "name": "When somebody cancels, tell billing" },
"trigger": { "kind": "event", "event": "subscription_cancelled" }
}
}
Two differences from a webhook, and both are deliberate:
- The secret is the workspace's own, not one per address, and it is derived rather than stored — there is nothing in a backup to leak and nothing to rotate. It is printed on Settings → Integrations under Outgoing webhooks, for owners and admins.
- One attempt, and no retries. A run held open for hours to knock again is worse than a line to read, so the answer becomes the run's own log line: "Posted to api.example.com: 200", or "…: 500, not retried", or "Could not post to …: <what went wrong>. Not retried." The address is checked against the public-https rule again at the moment it runs, because a definition can be edited after it was saved.
The automation language has this step beside the rest of them.
An automation's own address
The other direction: your server posts, and one automation starts, for the person the body names. An automation whose trigger is a webhook has an address of its own, printed on that automation's page and nowhere else:
POST https://www.kicklace.com/api/webhooks/automation/<automation id>.<signature>
{ "email": "ada@example.com" }
{ "anon_id": "kl_…", "properties": { "plan": "studio" } }
{ "record": "rec_…", "properties": { "message": "…" } }
There is no key. The address is the credential: the signature at the end of it is an HMAC of the automation's id under this deployment's own secret, so the address cannot be guessed from the id and cannot do anything but start that one automation for one person, in one workspace.
- The body names who it is for the way the events API does. An
emailmay make a person, on no list, with no consent; ananon_idand arecordonly ever find one that is already here. propertiesare your own facts about what happened. They are carried into the run for the steps that read them, and only their names are written into the run's log — never their values.- A paused automation, or one still a draft, answers
200with{ "ok": true, "status": "paused" }and writes nothing, because a refusal would only make your server try again. - Rate limited to 600 posts per address in ten minutes; 8 KB body.
- The address is never printed on a card, in a log, or in an answer to a model.
A post here starts no outgoing webhooks. Handing your own post straight back to your own addresses would be an echo, not news.
Where you see all of it
Your website's heartbeat on Settings → 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 Settings → Integrations, and read-only on Settings → Advanced → Developers, 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.