# Events

Source: https://kirora.io/docs/api/events

Name events and their properties, fill the context object, and use the events with a fixed meaning.

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`, or `POST /v1/events/batch` for up to
  100 at once. Name the contact by exactly one of `contactId`, `externalId` or `email`.
  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.

```json
{
  "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](#kiroras-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`. Not `upgrade_plan`, `orderCompleted` or `Completed 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 Viewed` with `{ "product_id": "8812" }`,
  not `Viewed product 8812`.
- **Names are case-sensitive.** `order completed` does not start a journey waiting for
  `Order Completed`.
- **Up to 128 characters, no control characters** (newlines, tabs). Leading and trailing
  spaces are removed: ` Order Completed ` is stored as `Order 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/catalog` reports the count in `meta.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](#deleting-a-name).

### Property names

- **Lowercase snake_case:** `order_id`, `plan_name`, `from_background`. This holds for
  all event data: an event's `properties`, a transactional send's `data`, 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 `properties` object 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 in
  `items.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 `total` that is sometimes `42.5` and 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](#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](https://kirora.io/docs/api/ecommerce-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_updated` names the field as `firstName`, or `attributes.<key>` and
  `dates.<key>` for your own fields. For a masked field, the event carries only
  `{ "field": "attributes.<key>", "masked": true }`, never its values.
- `contact.form_submitted` has `outcome` `created` for a new contact and `updated` for an
  existing one, and `source` is the form's own `source` label.

## 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:

```json
{
  "id": "0192f3a4-…",
  "status": "accepted",
  "name": "Order Completed",
  "contact": { "id": "0192f3a1-…", "externalId": "customer-881", "email": "ada@example.com" },
  "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](#kiroras-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](https://kirora.io/docs/api/ecommerce-events) (`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.

## Next steps

- [E-commerce events](https://kirora.io/docs/api/ecommerce-events): the standard commerce names and properties
- [Batches and asynchronous results](https://kirora.io/docs/api/batches): track up to 100 events in one call
- [Limits](https://kirora.io/docs/api/limits): every limit on events in one place