Apps and sources
An app is one of your products, and the only attribution unit: every event and profile carries its appId, and journey triggers and segment predicates filter by it, so a portfolio of SaaS apps streams into one org and a journey can react to one app’s events or deliberately span them all.
A source is one inbound pipe into an app. Every app is created with a source of kind api, the first-party SDK and HTTP path; a provider that pushes into the app (Clerk today) is a source of its own kind alongside it. Every source lands on its app, so “storefront via Clerk” and “storefront via the SDK” are one app and one set of profiles.
An app belongs to exactly one environment, chosen at creation and immutable, and its sources inherit it: everything written through them lands in that environment’s partition (profiles, events, deliveries, all of it). See Environments.
You hold two ids per product
Section titled “You hold two ids per product”This is worth saying plainly, because the two are not interchangeable:
| Id | Shape | Where it goes |
|---|---|---|
sourceId |
src_ plus an opaque TypeID |
SDK client config (or per call), and on every raw identify, track, and batch request |
appId |
app_ plus a readable slug |
Segment definitions, journey triggers, dashboard and CLI filters |
Ingestion names the pipe so that archiving a source actually revokes something: the id a caller was configured with stops working. The server derives the app from the source and stamps both ids on the row, which is why everything downstream filters by the app without your calls ever mentioning it.
Set up an app
Section titled “Set up an app”Apps are managed in the dashboard, the CLI, or the MCP server, same API either way:
cow apps create --name "storefront" # prints the new app, e.g. app_storefrontcow apps listcow apps sources list app_storefront # prints its api source, e.g. src_01j…The app id is a readable slug derived from the name at creation (storefront becomes app_storefront), unique per org: a name whose slug is taken is a 409 asking for a different name. A rename never changes the id.
Creating the app creates its api source in the same call, but the create response does not carry the source id, so read it back with cow apps sources list (or from the app’s page in the dashboard, where the card has a copy button). That src_ id is what the SDK is configured with:
import { Cow } from "@cowliss/sdk";
const cow = new Cow({ apiKey: process.env.COW_KEY!, sourceId: "src_01j2x8q7v9e3atn5m4kd7yz0bp",});
await cow.identify({ identifiers: { userId: "user_1" },});One client per app is the shape to reach for: the source is a deployment fact,
so it belongs in the client’s configuration rather than in every call. Pass
sourceId on a call to override it, which is what a process writing into two
apps does.
API keys stay org-level: the source comes from the payload, never the key, so one key serves every app you point at Cowliss.
Archiving an app stops it taking new sources and freezes its settings, but it keeps ingesting through the sources it already has, so a backfill against an archived app still lands. Events already stored keep their appId and sourceId forever.
Sources
Section titled “Sources”cow apps sources list <appId> lists an app’s pipes; each is addressed by its own id afterwards:
cow sources get src_…cow sources update src_… --config '{"signingSecret":"whsec_…"}'cow sources archive src_…Both kinds today are singleton, so an app holds at most one live source of each. The api source has an empty config and no credential of its own, because ingestion authenticates with the org API key; sending it a config with anything in it is a 422 rather than a silent no-op.
Archiving is revocation, and there is no undo
Section titled “Archiving is revocation, and there is no undo”Archiving a source refuses every call carrying its id with a 403. Turning that pipe back on means creating a fresh source, which gets a new id you have to configure your callers with. That is rotation, not restore, and it is the point: a pipe you can switch off without reconfiguring anyone was never really off.
Archived rows are kept rather than deleted, because events, profiles, and quarantine entries already carry their ids. The dashboard folds them behind a collapsed Archived sources disclosure on the app page, and the events feed’s source filter deliberately keeps listing them: historical events are exactly what you would filter for.
The ingestion errors, all three:
| What you sent | Answer |
|---|---|
A sourceId no source has |
422, because a bad id is a configuration error, not data |
| A provider source’s id (a Clerk source) | 422: well formed, but the wrong kind of pipe for identify |
| An archived source’s id | 403 |
Connecting Clerk
Section titled “Connecting Clerk”There is one provider kind today, clerk: your auth provider’s user lifecycle lands in profiles with no integration code. An app holds at most one Clerk source (one Clerk instance pushes into one app); a second is a 409.
Setup runs in the provider’s order, not Cowliss’s. Clerk only issues a signing secret once the endpoint exists, and that endpoint’s URL carries the source id, so the source is created first:
# 1. Create the Clerk source on the app it feeds. No secret yet.cow apps sources add app_storefront --data '{"kind":"clerk"}'
# 2. Register https://<your-api>/v1/sources/<src_id>/webhook in the Clerk# dashboard, subscribing to the user and organizationMembership events.
# 3. Paste back the signing secret Clerk issued for that endpoint.cow sources update src_… --config '{"signingSecret":"whsec_…"}'Between steps 1 and 3 the source is inert, not open: with no stored secret no signature can verify, so every delivery is rejected with a 400. The dashboard shows that state as Needs signing secret, then Awaiting first delivery, then Connected once a delivery verifies. An api source, which has nothing to paste, reads simply Active.
Two Clerk instances are two apps, each with its own Clerk source and its own webhook URL: storefront and storefront-dev coexist and their events stay separately attributable.
user.created and user.updated become an identify with identifiers: { "clerkId": … } and canonical traits, user.deleted becomes a system.profile_deleted system event, and organizationMembership.created, .updated, and .deleted are mapped onto the user’s orgs trait, a map of Clerk org id to role. Because the Clerk source identifies by clerkId, a delivery and your own SDK call naming the same clerkId land on one profile. Subscribe your Svix endpoint to all six if you want the membership lens; unhandled event types are acked and ignored.
The signing secret is write-only: it is stored on the source row and never appears in any DTO, which is why the API reports a configured boolean instead. Rotate it by setting the config again; the kind itself can never change.
What the app id is used for downstream
Section titled “What the app id is used for downstream”- Attribution: every ClickHouse event row and every Postgres profile carries
appId,sourceId, and the app’senvironment. The events feed and external BI group by the first and must filter on the last, or development traffic lands in production numbers. ThesourceIdcolumn answers “which pipe did this arrive through”, and the events feed takes it as a filter. - Journey triggers:
{ event: "plan_upgraded", appId: "app_b" }scopes a trigger to one app; omitappIdto span the org. See Journeys. - Segment predicates: a definition’s optional
appIdscopes its event side (traits merge across apps, so the trait side is unscoped). See Segments.
Neither triggers nor segment definitions can filter by source, deliberately: a stored definition naming a pipe that was later archived would parse fine and evaluate empty forever, which is a failure nobody sees. Read-time filters are safe where stored definitions are not, so the events feed has one and definitions do not.