Skip to content

Reference

API reference

Every address a server can post to, and everything each of them can answer. This page is generated from the routes themselves and from the package's own types, so a name that changes changes here in the same commit.

The events API

What a person did, from your own code. One event, or a batch.

Endpoint
POST /api/v1/events
Authorization
Authorization: Bearer sk_live_…
One event
{
  "type": "purchase",
  "email": "hank@ridgecabinets.example",
  "account_id": "acct_5f2a91",
  "value": 49,
  "idempotency_key": "stripe:evt_1Q…"
}
Types
identify, subscribe, support, pageview, configure, cta_click, waitlist_cta, measure_cta, download, activated, signup, purchase, credit_purchase
A name of your own
A name of your own is taken as a custom event: 2 to 40 characters of lowercase letters, digits and underscores, like credit_added. It lands on the person's timeline and an automation can answer it; it moves nobody between stages.
A batch
{"events": [ …up to 100… ]}
expires_at
Optional ISO 8601 on an event, after its occurred_at: the day Kicklace deletes that timeline row. One that has already passed answers {ok: true, status: "expired"} and is never written.

A GET on this address answers with the same facts, so curl tells you what to post. It never echoes a key, right or wrong.

The messages API

One email to one person, and everything that has been said to them and by them.

Send
POST /api/v1/messages
Read
GET /api/v1/messages
One person's thread
GET /api/v1/people/{id or email}/messages

What to post

to
an email address or a record id
subject
2 to 200 characters
body
2 to 20,000 characters, the same markup templates use
template
a template's name or id, instead of subject and body
What to ask for
?since=&until=&person=&direction=in|out&limit=1..100&cursor=
The rule
Kicklace only emails a person who bought, signed up, wrote in, replied, or joined a list. Anybody else is refused with not_engaged.
A page
At most 100 messages, newest first.

Refusals

not_engaged
You can only email someone who has been in touch — bought, signed up, written in, replied, or joined one of your lists — so put them on a list with the words they agreed to, or wait until they get in touch.
no_such_person
Nobody here has that address or that id. Post an identify or a subscribe first, and this address will find them.
no_email
That person has no email address, so add one to their record first.
unknown_template
There is no template of that name here. Automations → Templates lists the ones there are.
missing_body
Write the email, 2 to 20,000 characters.
invalid_subject
Give the email a subject of 2 to 200 characters.

Your website's address

The public one, with your workspace's public key in it. A static site posts here straight from the visitor's browser, so it can only add people and never read anything.

The four actions
POST {your base URL}/subscribe
POST {your base URL}/identify
POST {your base URL}/event
POST {your base URL}/support

The base URL is on Settings → Your website. Posts are taken only from the sites that workspace names, a field called hp or website is a honeypot that is accepted and thrown away, a body is capped at 8 KB, and a subscribe is refused without the exact words the person read.

Every refusal, and the sentence it carries

unknown_key
This address does not belong to a Kicklace workspace. Copy the site tag again from Settings → Your website.
unknown_action
There is nothing at this address. Post to subscribe, identify, event, or support.
origin_not_allowed
That site is not one of your sites. Add it in Kicklace under Settings → Your website.
too_large
That post is over 8 KB. Send less.
invalid_json
That post was not JSON. Send a JSON object.
rate_limited
Too many posts from this address in the last ten minutes. Wait, then send it again.
invalid_email
That is not an email address, so nobody was added.
unknown_list
Name the list to add them to. The names are on Settings → Your website.
missing_consent
Send consent.text: the exact words shown next to the form. Nobody joins a list without them.
nothing_to_identify
Send an email address, an anon_id, or an account_id.
no_identity
Send an email address, an anon_id, or an account_id with the event, so it lands on somebody.
unknown_event
Name the event to record.
invalid_expiry
That expires_at is not a date after the event's occurred_at. Send ISO 8601, like 2027-10-12T18:02:11Z.
empty_message
That support post carried no message.
server_error
Kicklace could not finish that. Nothing was changed; send it again.

What arrives by itself

Addresses somebody else's system posts to. Each is made when you connect that door, and each proves the post is really theirs.

Stripe
POST /api/webhooks/stripe/{token} — Stripe's own stripe-signature header: HMAC-SHA256 of the timestamp and the exact body, under the whsec_… you paste in. Five minutes of tolerance.
Clerk
POST /api/webhooks/clerk/{token} — The Standard Webhooks signature Svix sends, under the whsec_… from Clerk's dashboard.
Kinde
POST /api/webhooks/kinde/{token} — The body is itself an RS256 token, verified against your business's own JWKS. Nothing is pasted but the domain.
Vercel
POST /api/webhooks/vercel/{token} — Vercel's x-vercel-signature: HMAC-SHA1 of the exact body, under the secret its Webhook Created dialog shows once.
One automation of yours
POST /api/webhooks/automation/{automation id}.{signature} — The address itself, which is signed. It is on the automation's own page and nowhere else. A paused automation answers ok and writes nothing.
Resend
POST /api/webhooks/resend — The Standard Webhooks signature. One webhook for the whole deployment, so it is set up once by whoever runs Kicklace, not per workspace.

Outgoing webhooks

What Kicklace posts to an address of yours, every time something happens to somebody.

The body
{ id, type, at, workspace: { id, slug }, record: { id, kind, name, email?, stage? }, data } — `type` is the most specific name for what happened, and `data` carries that trigger's own facts.
The headers
X-Kicklace-Delivery is this delivery's id, X-Kicklace-Event is the trigger, and X-Kicklace-Signature is sha256=<hex HMAC-SHA256 of the exact body> under the secret shown when the address was added.
When it fails
Anything but a 2xx, a redirect, or no answer at all is tried again after 1, 5, 30, 120, 720 minutes — 6 attempts, about fifteen hours. A retry sends the same bytes, so the signature still holds.
What it will post to
A public https address only. Anything else — plain http, a private or link-local address, a bare hostname — is refused when the address is saved.

What you can ask to hear

stage_entered
Somebody reached a stage
subscribed
Somebody joined a list
unsubscribed
Somebody left a list
event
An event arrived, yours or one of Kicklace's own
email_in
Somebody wrote back
field_changed
A field on a record changed

The MCP door

One address, the Model Context Protocol over Streamable HTTP, stateless: one POST in, one JSON answer out.

Endpoint
POST /api/mcp
Authorization
Authorization: Bearer kla_…        a connector that signed in
Authorization: Bearer sk_live_…    a key you pasted

A GET or a DELETE answers 405 saying what to POST. The whole protocol, every tool, and the sign-in flow are on the MCP door's own page.

Refusals

use_the_public_key_from_browsers
The request carried an Origin header, so it came from a browser. A secret key belongs on a server.
invalid_key
The bearer token is not a key of any workspace. 401, with a WWW-Authenticate header saying where to sign in.
invalid_token
A connector's access token has run out, was revoked, or was issued for something else.
no_longer_a_member
The key acts as a person, and that person is no longer in the workspace.
too_large
The request is over 64 KB.
rate_limited
Too many calls on this key in the last ten minutes.
unsupported_protocol_version
The MCP-Protocol-Version header names a version this door does not speak.

The package

Everything `kicklace` offers, read off its own source.

  • Something a person did.

    kl.track(type: EventType, options?: EventOptions): Promise<Outcome<"recorded" | "expired">>

    kl.track(options: { type: EventType } & EventOptions): Promise<Outcome<"recorded" | "expired">>

  • Who somebody is.

    kl.identify(options: IdentifyOptions): Promise<Outcome<"created" | "merged" | "linked">>

  • Put somebody on a list, with the words they agreed to.

    kl.subscribe(options: SubscribeOptions): Promise<Outcome<"subscribed" | "already_subscribed">>

  • Something somebody wrote in: it lands on their timeline as support.

    kl.support(options: SupportOptions): Promise<Outcome<"recorded">>

  • Up to 100 of any of those in one request.

    kl.batch(items: BatchItem[]): Promise<BatchOutcome>

  • One email to one person, as the workspace's own sender.

    kl.messages.send(options: SendOptions): Promise<SendOutcome>

  • A page of everything said in this workspace, newest first.

    kl.messages.list(query: MessageQuery = {}): Promise<MessagesOutcome>

  • One person's thread, oldest first.

    kl.messages.thread(who: string): Promise<ThreadOutcome>

Constants

DEFAULT_URL = "https://www.kicklace.com"
Where Kicklace is, unless you say otherwise.
DEFAULT_TIMEOUT_MS = 5_000
How long one attempt may take.
DEFAULT_RETRIES = 3
How many attempts, counting the first.
MAX_BATCH = 100
The most events one request may carry, as the route counts them.

The shapes they take

Refusal

A refusal, in the server's own words.

ok: false
error: string
message: string
httpStatus: number

Answer

What a call that landed answers: the route's `status`, and the person's id.

ok: true
status: S
What the route says became of it.
id?: string
The record this landed on.

Outcome

Every method resolves to one of these.

Answer<S> | Refusal

BuiltInEvent

Kicklace's own event names.

| "pageview" | "configure" | "cta_click" | "waitlist_cta" | "measure_cta" | "download" | "activated" | "signup" | "purchase" | "credit_purchase"

EventType

A built-in name, or one of your own: 2 to 40 characters of lowercase letters, digits and underscores, like `credit_added`.

BuiltInEvent | (string & {})

EventSource

Where a visit came from, as the tag would have reported it.

path?: string
The path on the site, like `/pricing`.
referrer?: string
The referring site: a host, or a whole address, which is reduced to its host.
os?: "win" | "mac" | "linux" | "other" | (string & {})
Only `win`, `mac`, `linux` and `other` become a field on the person.
anonId?: string
The browser id the tag keeps, when this post is on a visitor's behalf.
utm?: { source?: string; medium?: string; campaign?: string; term?: string; content?: string; }
clickId?: string
`gclid`, `fbclid`, and the rest.

Identity

Who this is about.

email?: string
anonId?: string
The browser id from the tag (`kl_id`), so a visit and a purchase are one person.
accountId?: string
Your own id for them, so an event with no address still lands.

EventOptions — and everything in Identity

Everything an event may carry besides its type.

occurredAt?: string | Date
When it happened, ISO 8601.
expiresAt?: string | Date
Your own promise about how long this event may be kept, ISO 8601 and after `occurredAt`: the day Kicklace deletes that timeline row.
idempotencyKey?: string
Your id for the thing that happened.
value?: number
Money, for a purchase: whole currency units.
detail?: string
One line to show beside the event on the timeline.
properties?: Record<string, unknown>
Anything else worth keeping.
source?: EventSource

IdentifyOptions — and everything in Identity

What `identify` may carry.

name?: string
Their name, the one they typed.
fields?: Record<string, string | number | null>
Fields on the person's record, by key: `phone`, `title`, one you added.
source?: EventSource

Consent

The words the person read and agreed to, kept verbatim as the proof.

| string | { text: string; ip?: string; grantedAt?: string | Date; }

SubscribeOptions

What `subscribe` may carry.

email: string
list: string
The list's address, from Settings → Lists.
consent: Consent
anonId?: string
The browser id from the tag, so the visitor and the subscriber are one person.
sourceLabel?: string
Where this signup happened, in your own words.
source?: EventSource

SupportOptions

What `support` may carry.

from?: string
Their email address.
who?: string
Their name.
topic?: string
What it is about: "Billing", "A bug".
message: string
What they wrote.
version?: string
Your app's version, when it came from inside one.
anonId?: string
The browser id from the tag.
source?: EventSource

BatchItem

One item of a batch: an event, or one of the three named actions.

| ({ type: "identify" } & IdentifyOptions) | ({ type: "subscribe" } & SubscribeOptions) | ({ type: "support" } & SupportOptions) | ({ type: EventType } & EventOptions)

BatchResult

What became of one item of a batch, in the order it was sent.

index: number

BatchOutcome

A batch is `ok` only when every item was.

| { ok: true; results: BatchResult[] } | (Refusal & { results?: BatchResult[] })

SendAnswer

What an email that left answers with.

ok: true
id: string
The row on the person's timeline.
providerId?: string
Resend's own id, when a key is set and the email really left.

SendOutcome

SendAnswer | Refusal

SendOptions

What to send to one person: a subject and a body, or a template's name.

to: string
An email address or a record id.
subject?: string
2 to 200 characters.
body?: string
2 to 20,000 characters, the same markup templates use.
template?: string
A template's name or id, instead of a subject and a body.

Message

One email, sent or received.

id: string
direction: "in" | "out"
at: string
ISO 8601.
person: { id: string; name: string; email: string }
subject: string
text: string
providerId?: string
Resend's id, for one that left.
status?: "delivered" | "opened" | "clicked" | "bounced" | "complained"
What the provider last said about one that left.

MessageQuery

What to narrow a page of messages by.

since?: string | Date
ISO 8601, inclusive.
until?: string | Date
ISO 8601, inclusive.
person?: string
A record id or an email address.
direction?: "in" | "out"
limit?: number
1 to 100.
cursor?: string
From a previous page's `nextCursor`.

MessagePage

ok: true
messages: Message[]
Newest first.
nextCursor?: string
Ask again with this to read the page after it.

MessagesOutcome

MessagePage | Refusal

Thread

ok: true
person: string
The record id this thread belongs to.
messages: Message[]
Oldest first, the way a mail client prints it.

ThreadOutcome

Thread | Refusal

KicklaceOptions

How to reach a workspace.

key: string
A secret key, `sk_live_…`, from Settings → Your website → For your developers.
url?: string
Where Kicklace is.
fetch?: typeof globalThis.fetch
Your own `fetch`, for a runtime with a different one, or for a test.
timeoutMs?: number
How long one attempt may take.
retries?: number
How many times to try, counting the first.

Rate limits

How much one door takes before it says to slow down. Over the limit is a 429, and nothing is written.

WhatWhereCounted forHow many
A signup from your websitePOST {your base URL}/subscribeeach visitor's IP address, per workspace20 in 10 min
Naming a visitor from your websitePOST {your base URL}/identifyeach visitor's IP address, per workspace60 in 10 min
An event from your website, including page readsPOST {your base URL}/eventeach visitor's IP address, per workspace240 in 10 min
A support form on your websitePOST {your base URL}/supporteach visitor's IP address, per workspace10 in 10 min
Events and messages from your own serversPOST /api/v1/events, /api/v1/messageseach secret key600 in 10 min
Claude, through the connectorPOST /api/mcpeach key or signed-in connection600 in 10 min
A post that starts one automationPOST /api/webhooks/automation/{address}each automation's own address600 in 10 min