Tracking code
Everything in Cowliss starts with a write from your own application. There are two calls, identify and track, and one batch endpoint for history. This page is the whole tracking surface; the Quickstart is the four-minute version.
The shape of a write
Section titled “The shape of a write”Every call carries the same two things:
identifiers: a map naming who the write is about. Cowliss generates the profile id and resolves your map to it, so you never invent one and never send one back.{ userId: "user_1" },{ email: "[email protected]" },{ anonymousId, userId }together.sourceId: the pipe the write arrives through, asrc_id belonging to one of your apps. The server looks up the source, derives the app, and stamps both ids on what it stores.
identify writes traits, track writes an event. Both create the profile if it is not there yet, so there is no separate registration step and no ordering requirement between them.
The two ids you hold
Section titled “The two ids you hold”A product gives you two ids and they are not interchangeable:
| Id | Looks like | You use it for |
|---|---|---|
| Source id | src_01j2x8q7… |
Every write. It goes in your SDK config and on every HTTP body. |
| App id | app_my-app |
Segment definitions, journey triggers, filtering the event feed. |
You send the source; you filter on the app. An app can have several sources (its own api source, plus a provider such as Clerk), and everything they send lands on the same app and therefore the same profiles. See Apps and sources.
The TypeScript SDK
Section titled “The TypeScript SDK”npm install @cowliss/sdkimport { Cow } from "@cowliss/sdk";
const cow = new Cow({ apiKey: process.env.COW_KEY!, sourceId: "src_01j2x8q7v9e3atn5m4kd7yz0bp", baseUrl: process.env.COW_API_URL,});| Option | Default | Meaning |
|---|---|---|
apiKey |
required | Your org’s ingestion key, the Bearer credential on every request. One key serves every app. |
sourceId |
none | The source calls arrive through when they do not name one. Set it once per app. |
baseUrl |
local dev | The origin your Cowliss API is served at, with no trailing slash. |
maxRetries |
2 |
Retries after the first attempt, on network errors and 5xx only. |
fetch |
global | An injectable fetch, for tests and runtimes that do not have one. |
A process that writes into two apps names sourceId on the call instead, or holds one client per app.
identify
Section titled “identify”await cow.identify({ identifiers: { userId: "user_1" },});Traits merge: keys you send are written, keys you leave out are untouched, and a null value deletes the key. Segments recompute on the write, so the profile’s membership is correct the moment the call returns.
await cow.track({ identifiers: { userId: "user_1" }, event: "checkout_started", properties: { cartId: "c_9", total: 4200 },});| Field | Required | Notes |
|---|---|---|
identifiers |
yes | Who the event is about. |
event |
yes | Up to 200 characters. It may not start with system., which is reserved for the events Cowliss records itself. |
properties |
no | Any JSON object. Empty when omitted. |
timestamp |
no | ISO 8601 UTC, for a historical write. It may not be in the future. Defaults to now. |
messageId |
yes on the wire | The dedupe handle. The SDK generates one when you omit it. |
Naming is yours to choose, and worth choosing once: event names are the vocabulary your segments and journey triggers are written against. checkout_started and purchase_completed read better in a trigger than evt_3.
Errors
Section titled “Errors”A 4xx surfaces as a typed CowError with the API’s error code and is never retried. Network errors and 5xx retry with exponential backoff.
import { CowError } from "@cowliss/sdk";
try { await cow.track({ identifiers: { userId: "user_1" }, event: "checkout_started" });} catch (error) { if (error instanceof CowError && error.code === "over_quota") { // out of credit: the write did not land }}An unknown sourceId, an archived source, a reserved event name, and a timestamp in the future are all rejected rather than quietly dropped. The codes and their statuses are in API conventions.
Plain HTTP
Section titled “Plain HTTP”The SDK is a thin client over two endpoints, so any language works. Requests and responses use the { data } envelope.
curl -X POST $COW_API_URL/v1/identify \ -H "Authorization: Bearer $COW_KEY" \ -H "Content-Type: application/json" \ -d '{ "data": { "sourceId": "src_01j2x8q7v9e3atn5m4kd7yz0bp", "identifiers": { "userId": "user_1" }, "traits": { "email": "[email protected]", "plan": "pro" } } }'curl -X POST $COW_API_URL/v1/track \ -H "Authorization: Bearer $COW_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: msg-checkout-1" \ -d '{ "data": { "sourceId": "src_01j2x8q7v9e3atn5m4kd7yz0bp", "identifiers": { "userId": "user_1" }, "event": "checkout_started", "properties": { "cartId": "c_9" }, "messageId": "msg-checkout-1" } }'A raw client must send messageId itself. The full request and response schemas are on the generated Ingestion page.
Retries are safe
Section titled “Retries are safe”messageId is the dedupe key, and it doubles as the Idempotency-Key header. Send the same one twice and the event is stored once, which is what makes a retry after a lost response safe rather than a double write. Use a value derived from the thing that happened (an order id, a request id), not a fresh random per attempt, and a retry loop of your own dedupes the same way the SDK’s does.
Where a write lands
Section titled “Where a write lands”One track call, synchronously, before the response: the profile is resolved or created, traits merge, segments recompute, and any journey whose trigger matches starts an execution. There is no queue to wait on and no eventual consistency to design around, so a read straight after a write sees it.
Two consequences worth knowing early:
- Sending two identifiers in one call links them. There is no alias call. See Identity.
- A write into an app in the
developmentenvironment stays there: separate profiles, separate events, separate journey executions. See Environments.
Importing history
Section titled “Importing history”Backfill goes through /v1/batch, which takes many items in one request, accepts historical timestamps, and writes quietly so a year of old orders does not fire a year of journeys at people. See Backfill and batch.
- Apps and sources: a second app, and connecting Clerk so your auth provider’s users flow in with no integration code.
- Identity: what happens when one person arrives under two identifiers.
- Segments: turning the traits and events you just sent into audiences.