Skip to content

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.