Reference
API reference
The events API
What a person did, from your own code. One event, or a batch.
POST /api/v1/eventsAuthorization: Bearer sk_live_…{
"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.
POST /api/v1/messagesGET /api/v1/messagesGET /api/v1/people/{id or email}/messagesWhat 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.
POST {your base URL}/subscribe
POST {your base URL}/identify
POST {your base URL}/event
POST {your base URL}/supportThe 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.
POST /api/mcpAuthorization: Bearer kla_… a connector that signed in
Authorization: Bearer sk_live_… a key you pastedA 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.
| What | Where | Counted for | How many |
|---|---|---|---|
| A signup from your website | POST {your base URL}/subscribe | each visitor's IP address, per workspace | 20 in 10 min |
| Naming a visitor from your website | POST {your base URL}/identify | each visitor's IP address, per workspace | 60 in 10 min |
| An event from your website, including page reads | POST {your base URL}/event | each visitor's IP address, per workspace | 240 in 10 min |
| A support form on your website | POST {your base URL}/support | each visitor's IP address, per workspace | 10 in 10 min |
| Events and messages from your own servers | POST /api/v1/events, /api/v1/messages | each secret key | 600 in 10 min |
| Claude, through the connector | POST /api/mcp | each key or signed-in connection | 600 in 10 min |
| A post that starts one automation | POST /api/webhooks/automation/{address} | each automation's own address | 600 in 10 min |