An event records something a contact did: signed up, viewed a page, completed an order.
This page is Kirora’s event spec: how to name events and their properties, what the
context object holds, and the events with a fixed meaning.
What an event is
Every event has:
| Field | What it holds |
|---|---|
name |
What happened, such as Order Completed |
properties |
Details of this occurrence, such as { "order_id": "10042", "total": 125.5 } |
occurredAt |
When it happened, as ISO 8601 with Z or an offset. Defaults to now, and may be at most 24 hours ahead |
| Idempotency key | Optional, and recommended: your id for this occurrence. A retry with the same key records nothing new; without one, every request records a new event |
context |
Where and on what it happened: the page, the device, the visit |
Events come from two places:
- Your server, through
POST /v1/events/track, orPOST /v1/events/batchfor up to 100 at once. Name the contact by exactly one ofcontactId,externalIdoremail. The contact must already exist: an event for an unknown contact is refused with 404 and dropped, so identify the contact first. - Kirora’s web SDK, which tracks events for the user signed in to your site.
{
"externalId": "customer-881",
"name": "Order Completed",
"properties": { "order_id": "1042", "total": 46.5, "currency": "USD" },
"occurredAt": "2026-10-24T09:30:00.000Z"
}A track answers 201 with a receipt, not the event: its id, the occurredAt it was
stored with (the time you sent, or the time it arrived when you sent none) and its
expiresAt.
An event that arrives more than 72 hours after its occurredAt starts no journey: it is
history being backfilled, such as an order import. It is still stored, and still counts
for segments, journey exits and waits.
Both count against the plan’s monthly event allowance (1,000 events on Free). Past it, a
paid plan draws extra events from the prepaid balance; with none left, events are still
accepted up to the plan’s tolerance, then each is refused with 403 PLAN_LIMIT_REACHED
and listed in the event log. Free stops at its allowance. A replay of the same
idempotency key, an order’s line events and Kirora’s own events
never count.
Segments, journeys and conversion goals all read events. A segment can match contacts who
did an event, how often and with which properties. A journey can start when an event
arrives, and its messages can read the event’s properties with
{{ event.properties.<name> }}. A conversion goal counts an event as a conversion, and
can add up one of its properties as revenue.
How long events are kept
An event is kept for the plan’s event retention, counted from when it happened: 30 days
on Free, up to a year on paid plans. Its expiresAt is fixed when it is
stored, so changing plan moves only the expiry of events stored afterwards: an upgrade
never lengthens history you already have, and a downgrade never shortens or hides it. An
event that happened longer ago than the plan keeps events is refused with 400
VALIDATION_ERROR on occurredAt, since it would expire at once.
Naming
Event names
- Title Case, object then past-tense action:
Plan Upgraded,Order Completed,Invoice Paid. Notupgrade_plan,orderCompletedorCompleted Order. - One fixed name per action. Anything that varies, such as an id, a price or a plan,
goes in
properties, never in the name.Product Viewedwith{ "product_id": "8812" }, notViewed product 8812. - Names are case-sensitive.
order completeddoes not start a journey waiting forOrder Completed. - Up to 128 characters, no control characters (newlines, tabs). Leading and trailing
spaces are removed:
Order Completedis stored asOrder Completed. - At most 1,000 distinct names per workspace, names defined before they are sent
included. A new name past that is refused with
EVENT_NAME_LIMIT_REACHED.GET /v1/events/catalogreports the count inmeta.nameUsage.DELETE /v1/events/catalog/{name}removes a name sent by mistake, with all its events, unless a journey, segment, campaign or goal still reads it; see Deleting a name.
Property names
- Lowercase snake_case:
order_id,plan_name,from_background. This holds for all event data: an event’sproperties, a transactional send’sdata, and the properties of Kirora’s own events. The fields the API itself defines (name,occurredAt,contactId) are camelCase. - Up to 64 characters per name, and the whole
propertiesobject at most 16 KB as JSON. - Filter on nested values with a dotted path. Segment rules, journey conditions and
conversion goals read a nested value as
order.total(a number indexes a list, as initems.0.price). A flat key with exactly that name takes precedence. Templates can also read nested values and repeat a block over a list. - Send the same type every time. A
totalthat is sometimes42.5and sometimes"42.50"makes rules like “total greater than 40” unreliable. - Send amounts in major units: dollars, not cents.
The context object
context sits beside properties and describes where and on what the event happened.
Properties belong to your app; context has one definition for every SDK, so a field
means the same thing on web and mobile.
Kirora’s web SDK fills context for you. From your server it is optional: send the fields
you know, and Kirora stores them as sent. Every field is optional, and a field not listed
here is refused. A page or campaign value that is too long is cut, not refused:
page.url, page.search and page.referrer at 8,192 characters, page.path at 2,048,
page.title and each campaign field at 256. Segments and journeys read properties, not context.
| Field | Holds | Filled by |
|---|---|---|
sessionId |
The visit the event belongs to | Web SDK (mobile SDKs, when they ship) |
device.id |
A random id the SDK keeps in the site’s or app’s own storage. Never a hardware or advertising id. It goes with an uninstall or cleared site data | Web SDK (mobile SDKs, when they ship) |
locale |
The user’s language, as BCP 47: en-GB |
Web SDK (mobile SDKs, when they ship) |
timezone |
The user’s time zone, as IANA: Europe/Berlin |
Web SDK (mobile SDKs, when they ship) |
page.url |
The page’s canonical URL if it has one, with the page’s query string added unless the canonical URL has its own; else the page’s address. Query string kept, fragment removed | Web SDK |
page.path |
The page’s path: /pricing |
Web SDK |
page.search |
The page’s query string as it is, ?plan=pro, empty or absent when there is none. Always the visited address’s, even with a canonical URL |
Web SDK |
page.title |
The page’s title | Web SDK |
page.referrer |
The full address of the page the user came from, when there is one | Web SDK |
campaign.source |
The page’s utm_source |
Web SDK |
campaign.medium |
The page’s utm_medium |
Web SDK |
campaign.name |
The page’s utm_campaign |
Web SDK |
campaign.term |
The page’s utm_term |
Web SDK |
campaign.content |
The page’s utm_content |
Web SDK |
ip |
The IP address the request came from | Kirora, from the request |
userAgent |
The request’s User-Agent header |
Kirora, from the request |
library.name, library.version |
The SDK or server library that sent the event: kirora-web, 1.0.0 |
Kirora, from the request |
For events from its SDKs, Kirora sets ip, userAgent and library itself and replaces
anything the device sent for them. From your server it adds only library, when the
request comes from a Kirora server library (its User-Agent starts with the library’s
name and version, such as kirora-node/0.1.0, kirora-go/0.1.0 or kirora-dotnet/0.1.0) and the event has no
library of its own; your
server’s IP address and user agent say nothing about the contact, so they are not kept.
The web SDK sends page addresses and referrers with their query strings, and masks
nothing. If a page’s address can hold a token or an email
address (a password-reset link, say), remove it from the address before the page is
tracked, for example with history.replaceState.
Reserved names
Kirora’s web SDK tracks the events under Standard events, and Kirora
gives those names their meaning: the same name means the same thing in every workspace.
You may send them yourself, from your server or with track(), but use the properties
documented here.
Names beginning with contact. are Kirora’s own. Tracking one is refused with
VALIDATION_ERROR, and they cannot be deleted.
Standard events
Session Started
A visit begins. The web SDK tracks it unless you turn sessions off. A visit ends after 30 minutes with no activity.
| Property | Type | Notes |
|---|---|---|
path |
string | Web: the landing page’s path |
referrer |
string | Web: the full address of the page the user came from |
utm_source, utm_medium, utm_campaign, utm_term, utm_content |
string | Web: each present on the landing page’s address |
The mobile SDKs, when they ship, will track Session Started without properties.
Page Viewed
Web: a page was viewed. The web SDK tracks it when you call trackPageView(), or on every
page once you turn on automatic page views.
| Property | Type | Notes |
|---|---|---|
url |
string | As page.url: the canonical URL if the page has one, else the page’s address, query string kept |
path |
string | The page’s path |
search |
string | As page.search: the page’s query string; empty or absent when there is none |
title |
string | The page’s title |
referrer |
string | The full address of the page the user came from, when there is one |
Screen Viewed
Mobile: a screen was viewed. Your app calls trackScreenView(name); screens are never
tracked automatically.
| Property | Type | Notes |
|---|---|---|
name |
string | The screen’s name, as your app calls it: Checkout |
Application lifecycle
Mobile: tracked when your app turns on lifecycle events. Each is off until then.
| Event | When | Properties |
|---|---|---|
Application Installed |
The first launch after install | version, build |
Application Opened |
Each launch, and each return to the foreground | from_background, version, build |
Application Updated |
The first launch of a new version | version, build, previous_version, previous_build |
Application Backgrounded |
The app leaves the foreground | None |
| Property | Type | Notes |
|---|---|---|
version |
string | The app’s version: 2.4.0 |
build |
string | The app’s build number: 412 |
from_background |
boolean | true when the app returned from the background, false on a fresh launch |
previous_version |
string | The version before the update |
previous_build |
string | The build before the update |
Commerce
Product, cart, checkout and order events have their own page: E-commerce events.
Kirora’s own events
Kirora records these itself. Segments and journeys can use them like any event: start a
welcome journey on contact.form_submitted, or build a segment of contacts added to a
list this week. You cannot track them, and they do not count toward the 1,000 names.
| Event | When | Properties |
|---|---|---|
contact.added_to_list |
A contact was newly added to a list | list_id, list_name |
contact.entered_segment |
A contact began matching a segment, found within 15 minutes | segment_id, segment_name |
contact.exited_segment |
A contact stopped matching a segment | segment_id, segment_name |
contact.field_updated |
A stored contact field changed value | field, value, previous_value |
contact.form_submitted |
A form submission was accepted for a contact | form_id, form_name, outcome, source |
contact.field_updatednames the field asfirstName, orattributes.<key>anddates.<key>for your own fields. For a masked field, the event carries only{ "field": "attributes.<key>", "masked": true }, never its values.contact.form_submittedhasoutcomecreatedfor a new contact andupdatedfor an existing one, andsourceis the form’s ownsourcelabel.
Reading events
Every read takes an event’s name as a query parameter, never in the path, since a name
may hold /, % or #. Encode it as any query value: ?name=Order%20Completed.
Reading needs events:read.
The event object
GET /v1/events and GET /v1/contacts/{id}/events return events in one shape, split on
status. An accepted event:
{
"id": "0192f3a4-…",
"status": "accepted",
"name": "Order Completed",
"contact": { "id": "0192f3a1-…", "externalId": "customer-881", "email": "[email protected]" },
"properties": { "order_id": "1042", "total": 46.5, "currency": "USD" },
"context": {
"library": { "name": "kirora-web", "version": "1.4.0" },
"page": { "url": "https://shop.example/checkout" }
},
"source": "web",
"occurredAt": "2026-10-24T09:30:00.000Z",
"receivedAt": "2026-10-24T09:30:01.204Z"
}A rejected event is one the API refused. It has no contact, properties or context;
it carries the request as sent in payload (null when it was over 32 KB) and the
error the API answered. Its name and occurredAt are null when the request had no
valid one.
source says how the event came in. Kirora sets it from the path the request took, never
from the payload:
source |
The event came from |
|---|---|
api |
Your server, with an API key |
web |
Kirora’s web SDK |
system |
Kirora itself: Kirora’s own events |
segment |
A Segment source or destination, through /segment/v1 |
stripe |
The Stripe connection |
zapier, make, n8n |
A key made for that app |
A key made for an app labels its events with that app, whoever uses the key: an event
sent with a Zapier key from your own server still says zapier. The SDK’s name and
version are in context.library.
The event log
GET /v1/events lists every event the workspace tracked in the last 30 days, accepted or
rejected, newest first: did your site’s event arrive, and if not, why? Filter by name,
status (accepted or rejected), source, occurredAfter and occurredBefore, or
search with query for an event name or a contact’s id, email address or external id.
GET /v1/contacts/{id}/events lists one contact’s accepted events, filtered the same way
by name, occurredAfter and occurredBefore.
The catalogue
GET /v1/events/catalog lists your workspace’s event names: the ones Kirora records
(origin: "system"), and each name you have sent or defined, standard when it is one of
Kirora’s standard names and custom otherwise. Each comes with its description, when it
was last seen (lastSeenAt), its events in the last 30 days (last30DaysCount) and how
many journeys, segments, campaigns and goals read it (usedByCount); recorded is false
for a name with no events yet. The figures are counted at most 15 minutes before
meta.countedAt, and a name sent for the first time is listed at once.
query (part of the name, any case), origin and recorded filter, and sort orders, over
the whole catalogue rather than one page: -lastSeenAt (the default, names never seen
last), -last30DaysCount, name, or any of them reversed. The first page carries
meta.nameUsage.
GET /v1/events/standard-names lists the standard names you have not sent yet, with what
each means: the e-commerce names (commerce) and, for a product
contacts sign up to, Signed Up, Signed In, Account Created, Invite Sent,
Trial Started and Trial Ended (saas), as Segment’s specs name them. Offer them beside
the catalogue in a picker; once you send one, it moves to the catalogue.
Defining a name before you send it
PUT /v1/events/catalog/{name} with { "description": "…" } adds a name before your app
sends it, so a journey, segment or goal can be built on it while your developers add the
tracking. Send events with exactly that name and its figures fill in. The same call sets
or changes the description of any name already listed (null removes it); a description
is shown on the Data index and the event’s page, and lets Kiro explain the event. A name
new to the workspace counts against the 1,000-name limit. It takes events:write.
An event’s summary and series
GET /v1/events/summary?name=… says whether an event is alive before you build on it:
when it last happened (lastSeenAt), how many happened in the last 30 days (counted up to
100,000; last30Days.countExact is false past it), the property keys its latest 1,000
events carry with sample values, and the journeys, segments, campaigns, conversion goals
and active workspace goals that read it (up to 50 of each; usedBy.hasMore says when
there are more). A workspace goal reads every event its measure names: the counted event,
a conversion’s entry and success, a ratio’s two sides and a cohort’s population rules. A
retired goal is not listed: its history is settled and it never measures again. A custom
name neither tracked nor defined is a 404.
GET /v1/events/series?name=…&interval=day&from=2026-10-01&to=2026-10-25 counts accepted
events per hour or day in the workspace timezone, without gaps and at most 366 points,
each named by the instant its bucket starts (startsAt). A line that falls to zero after a
release shows tracking that broke, which a total hides. Refused events are not counted:
read them in the log with ?status=rejected&name=….
Deleting a name
DELETE /v1/events/catalog/{name} deletes a name sent by mistake and every event tracked under
it. It answers 202: the name leaves the catalogue and the 1,000-name limit at once, and
its events are removed within the hour; until then tracking the name returns 409
CONFLICT. URL-encode the name in the path (Order%20Completed). A name a journey,
segment, campaign, conversion goal or active workspace goal reads is refused with 409
IN_USE, naming them in details.usedBy (workspace goals in usedBy.goals). It cannot
be undone. A name you defined but never sent just leaves the catalogue.
It needs events:delete, a scope apart from events:write so a key that sends events
cannot delete them, and which agent keys cannot hold.