Apps and sources
An app is one of your products, and the only attribution unit: every event and every profile carries its appId. A portfolio of SaaS products streams into one org, and the app is the line between them: a profile, a segment and an automation each belong to exactly one app, and an automation only ever sees its own app’s events and people.
A source is one inbound pipe into an app. Every app is created with one source of kind api, the first-party SDK and HTTP path, and may hold more of them, one per caller you want to tell apart; a provider that pushes into the app (Clerk today) is a source of its own kind alongside them. Every source lands on its app, so “storefront via Clerk” and “storefront via the SDK” are one app and one set of profiles.
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 |
cow.json, and the dashboard and CLI filters |
Ingestion names the pipe so that deactivating a source actually switches something off: the id a caller was configured with stops working until the source is activated again. The server derives the app from the source and stamps it 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 that id, so read it back with cow apps sources list (or from the app’s page in the dashboard, where the row has a copy button). That src_ id is what the SDK is configured with. Keep it in the environment next to your key, here COW_SOURCE_ID:
import { Cow } from "@cowliss/sdk";
const cow = new Cow({ apiKey: process.env.COW_KEY!, sourceId: process.env.COW_SOURCE_ID!,});
await cow.identify({ identifiers: { userId: "user_1" },});One client per app is the shape to reach for: the source is a fact about where
the code runs, 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.
An app’s Brand tab in the dashboard (right after Health) is the one place you set how the app looks to the people it mails. It applies to every broadcast, to the signup confirm email, and to every hosted page: the signup form, check your inbox, confirm, subscribed, the unsubscribe pages and the preferences page. Each group saves on its own, and a preview beside them shows the signup email and page with your unsaved changes.
- Business name and website. The pages and the confirm email name the app by its business name (its own name when that is empty, which is yours to tell apps apart) and link “Back to” its website (https only) when one is set.
- Look. A logo, six colours (background, text, headings, links, button, button text), a font and the postal address under a broadcast’s footer. The logo shows at its own size, scaled down to fit 200 by 64 pixels and never up, so a small logo stays sharp.
- Sender. The address the confirm email and new broadcasts come from, and a reply-to. Until it is set, a signup sends no confirm email and the signup page says the list cannot take signups right now; app health warns while the app has a list and no sender.
- Language. English, French, Romanian or Spanish, for the confirm email and the hosted pages. Broadcasts are in whatever language you write them.
From the CLI, the business name and website are the app’s publicName and returnUrl, and the rest is its look:
cow apps update app_storefront --public-name "Acme" --return-url "https://acme.example/account"cow apps update app_storefront --public-name "" --return-url "" # clear bothcow apps look get app_storefrontSee Unsubscribe links.
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 apps sources add app_storefront --data '{"kind":"api","name":"backend"}'cow sources get src_…cow sources update src_… --name workercow sources deactivate src_…cow sources activate src_…Every source has a name, unique among its app’s active sources. An app may hold any number of api sources: the one created with the app is named api, and each one you add is named by you, so the events feed can say which of your callers sent what. A Clerk source is one per app and named clerk. An 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.
Deactivating switches a pipe off until you activate it
Section titled “Deactivating switches a pipe off until you activate it”Deactivating a source refuses every call carrying its id with a 403 source_inactive, “This source is inactive. Activate it to send events again.”, and a Clerk source’s webhook URL stops accepting deliveries. Activating it takes the same id back, so nothing has to be reconfigured. To move a caller to a new id instead, add a second api source, point the caller at it, then deactivate the first.
Inactive sources are kept rather than deleted, because events and profiles already carry their ids. The app page lists them with their status, and the events feed’s source filter keeps listing them: historical events are exactly what you would filter for.
What a mistyped or borrowed id does:
| 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 inactive source’s id | 403 source_inactive |
Another app’s api source id, in your organization |
200, and the profiles land in that app |
The last row is the one to watch: an API key is org-scoped, so every active api source in the organization is a legitimate destination and nothing in the answer says which app it was. cow whoami --key ak_… --source src_… reads the pair back before the first write.
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 active 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, and a deactivated source of either kind reads Inactive.
user.created and user.updated become an identify with identifiers: { "clerkId": … } and canonical traits. The account lifecycle also lands as three system events: user.created writes system.signed_up right after its identify, so a welcome automation can trigger on it and still read the traits; session.created writes system.logged_in; user.deleted writes system.profile_deleted. 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 seven if you want the session and membership lenses; unhandled event types are acked and ignored.
One caveat on system.signed_up: a bulk user import fires user.created once per user, so an automation triggered on it enrolls every imported profile. Gate it on a trait if that is not what you want.
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 event and every profile carries
appIdandsourceId. The events feed and external BI group by the app. ThesourceIdcolumn answers “which pipe did this arrive through”, the dashboard shows it by the source’s name, and the events feed takes it as a filter. - Identity: identifiers are unique per
(org, app, kind, value), so a person known to two of your products is two profiles. See Identity. - Automations and segments: each belongs to one app, and reads that app’s events and people only. Nothing in an automation file or a segment definition names an app:
cow.jsonnames the app the repo pushes to, and every row it creates inherits it. See Your repo. - The event list: the app’s
cow.jsonmay list the events it sends, and each name the app receives is then tagged as listed or not incow.json. The list tags and never refuses: an unlisted event is stored, counted and acted on like any other. Seeeventsin cow.json. - Filters: every data list (users, segments, automations, events, executions, deliveries) takes
?appId=, in the dashboard, the CLI and the API.
An automation trigger and a segment definition cannot filter by app, and cannot filter by source either. The app is not a filter but the row’s own boundary, and a stored definition naming a pipe that was later deactivated 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.