@sprid/cli 1.0.5 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/commands/ads.mjs +24 -19
- package/src/docs/commands.mjs +2 -2
- package/src/docs/guides.generated.mjs +10 -4
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,20 @@
|
|
|
3
3
|
sprid follows semver: a breaking change to a command's arguments, its output
|
|
4
4
|
shape or its exit codes is a major release.
|
|
5
5
|
|
|
6
|
+
## 1.1.1 - 2026-10-05
|
|
7
|
+
|
|
8
|
+
- `sprid docs posthog` explains why a website breakdown reads Unknown and what to send
|
|
9
|
+
instead: which PostHog property each of countries, sources and entry pages reads,
|
|
10
|
+
why cookieless mode, a relay or `$geoip_disable` loses the country, and what a relay
|
|
11
|
+
must forward (`$geoip_country_code`, `$referring_domain`, `$session_id`).
|
|
12
|
+
|
|
13
|
+
## 1.1.0 - 2026-10-04
|
|
14
|
+
|
|
15
|
+
- `sprid ads launch` and `sprid ads budget` print what the change would commit and open
|
|
16
|
+
the page in the Sprid app where you press Launch or set the budget. The server no
|
|
17
|
+
longer lets any token start spending, so this applies to every earlier version too;
|
|
18
|
+
`--yes` has nothing left to skip, and `launch` exits 0 after the hand-off.
|
|
19
|
+
|
|
6
20
|
## 1.0.5 - 2026-10-03
|
|
7
21
|
|
|
8
22
|
- `sprid docs posthog` and `sprid docs revenuecat` say how to fix each measurement check a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sprid/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "The Sprid command line: connect your app, review results, prepare posts and store screenshots, and release mobile apps.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "module",
|
package/src/commands/ads.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// sprid ads: paid campaigns on Meta through the same tools the ads skill
|
|
2
2
|
// calls. Everything is created paused. The two verbs that change what is spent
|
|
3
|
-
// (launch, budget)
|
|
4
|
-
//
|
|
3
|
+
// (launch, budget) print what it would commit and open the page in the Sprid
|
|
4
|
+
// app where you confirm it: no token can start spending.
|
|
5
5
|
|
|
6
6
|
import { UsageError } from "../args.mjs";
|
|
7
7
|
import { C, OK } from "../format.mjs";
|
|
@@ -12,8 +12,8 @@ const USAGE = [
|
|
|
12
12
|
"sprid ads [overview] --account <slug>",
|
|
13
13
|
"sprid ads status|council|metrics <adSetId> --account <slug> [--days N]",
|
|
14
14
|
"sprid ads create campaign|set|creative|ad|batch --file <args.json> --account <slug>",
|
|
15
|
-
"sprid ads launch <adSetId> --account <slug>
|
|
16
|
-
"sprid ads budget <adSetId> --daily-cents N --account <slug>
|
|
15
|
+
"sprid ads launch <adSetId> --account <slug>",
|
|
16
|
+
"sprid ads budget <adSetId> --daily-cents N --account <slug>",
|
|
17
17
|
"sprid ads pause <adSetId> --account <slug> [--yes]",
|
|
18
18
|
"sprid ads close <adSetId> --account <slug>",
|
|
19
19
|
].join("\n ");
|
|
@@ -48,8 +48,8 @@ export async function ads(ctx) {
|
|
|
48
48
|
show(ctx, await callTool(ctx, tool, { ...(await readJson(ctx, ctx.flags.file)), account }));
|
|
49
49
|
return 0;
|
|
50
50
|
}
|
|
51
|
-
if (sub === "launch") return launch(ctx, account, needId(arg, "sprid ads launch <adSetId> --account <slug>
|
|
52
|
-
if (sub === "budget") return budget(ctx, account, needId(arg, "sprid ads budget <adSetId> --daily-cents N --account <slug>
|
|
51
|
+
if (sub === "launch") return launch(ctx, account, needId(arg, "sprid ads launch <adSetId> --account <slug>"));
|
|
52
|
+
if (sub === "budget") return budget(ctx, account, needId(arg, "sprid ads budget <adSetId> --daily-cents N --account <slug>"));
|
|
53
53
|
if (sub === "pause") return pause(ctx, account, needId(arg, "sprid ads pause <adSetId> --account <slug> [--yes]"));
|
|
54
54
|
if (sub === "close") {
|
|
55
55
|
show(ctx, await callTool(ctx, "close_ad_set", { account, adSetId: needId(arg, "sprid ads close <adSetId> --account <slug>") }));
|
|
@@ -59,7 +59,22 @@ export async function ads(ctx) {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
62
|
+
* Spending is confirmed in the app. Print what it commits and open the page
|
|
63
|
+
* with the button on it.
|
|
64
|
+
*/
|
|
65
|
+
async function handOff(ctx, approval) {
|
|
66
|
+
const url = approval?.approvalUrl;
|
|
67
|
+
const opened = url ? await ctx.openBrowser(url) : false;
|
|
68
|
+
if (ctx.json) ctx.out({ ...approval, opened });
|
|
69
|
+
else {
|
|
70
|
+
ctx.print(` ${approval?.message ?? "Confirm it in the Sprid app."}`);
|
|
71
|
+
if (url) ctx.print(` ${opened ? "Opened" : "Open"} ${url}`);
|
|
72
|
+
}
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Ask before a call that resets learning. --yes skips the prompt.
|
|
63
78
|
* Without a terminal (or with --json) there is nobody to ask, so it stops
|
|
64
79
|
* with exit 2; a person who answers no gets exit 1.
|
|
65
80
|
*/
|
|
@@ -81,23 +96,13 @@ async function approve(ctx, summary, question) {
|
|
|
81
96
|
}
|
|
82
97
|
|
|
83
98
|
async function launch(ctx, account, adSetId) {
|
|
84
|
-
|
|
85
|
-
const stop = await approve(ctx, preview?.message ?? `Launch ad set ${adSetId}.`, "Launch and start spending?");
|
|
86
|
-
if (stop) return stop;
|
|
87
|
-
const res = await callTool(ctx, "launch_ads", { account, adSetId, confirmed: true });
|
|
88
|
-
if (ctx.json) ctx.out(res);
|
|
89
|
-
else ctx.print(` ${C.ok(OK)} Ad set ${adSetId} is live. Leave it alone for 3 days: every edit resets Meta's learning phase.`);
|
|
90
|
-
return 0;
|
|
99
|
+
return handOff(ctx, await callTool(ctx, "launch_ads", { account, adSetId }));
|
|
91
100
|
}
|
|
92
101
|
|
|
93
102
|
async function budget(ctx, account, adSetId) {
|
|
94
103
|
const cents = Number(ctx.flags["daily-cents"]);
|
|
95
104
|
if (!Number.isInteger(cents) || cents <= 0) throw new UsageError("Pass the new daily budget in cents: --daily-cents 1500 is 15.00 a day.");
|
|
96
|
-
|
|
97
|
-
const stop = await approve(ctx, summary, "Change the budget?");
|
|
98
|
-
if (stop) return stop;
|
|
99
|
-
show(ctx, await callTool(ctx, "update_ad_budget", { account, adSetId, dailyBudgetCents: cents }));
|
|
100
|
-
return 0;
|
|
105
|
+
return handOff(ctx, await callTool(ctx, "update_ad_budget", { account, adSetId, dailyBudgetCents: cents }));
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
async function pause(ctx, account, adSetId) {
|
package/src/docs/commands.mjs
CHANGED
|
@@ -118,8 +118,8 @@ export const COMMAND_GROUPS = [
|
|
|
118
118
|
['ads', 'sprid ads [overview] --account <slug>', 'Spend against the cap, what is live and what the connected Meta Ads channel can do.'],
|
|
119
119
|
['ads', 'sprid ads status|council|metrics <adSetId> --account <slug> [--days N]', 'Delivery and rejections from Meta, the weekly judgement, or the numbers for one ad set.'],
|
|
120
120
|
['ads', 'sprid ads create campaign|set|creative|ad|batch --file <args.json> --account <slug>', 'Create paused objects. The file holds the tool’s arguments; sprid tool describe create_ad_set shows them.'],
|
|
121
|
-
['ads', 'sprid ads launch <adSetId> --account <slug>
|
|
122
|
-
['ads', 'sprid ads budget <adSetId> --daily-cents N --account <slug>
|
|
121
|
+
['ads', 'sprid ads launch <adSetId> --account <slug>', 'Show what launching commits and open the page in Sprid where you press Launch. No token can start spending.'],
|
|
122
|
+
['ads', 'sprid ads budget <adSetId> --daily-cents N --account <slug>', 'Show the proposed daily budget and open the page in Sprid where you set it, checked against the account’s caps.'],
|
|
123
123
|
['ads', 'sprid ads pause <adSetId> --account <slug> [--yes]', 'Pause an ad set. Resuming restarts Meta’s learning phase.'],
|
|
124
124
|
['ads', 'sprid ads close <adSetId> --account <slug>', 'End an ad set and write its post-mortem.'],
|
|
125
125
|
] },
|
|
@@ -360,6 +360,12 @@ export const GUIDES = [
|
|
|
360
360
|
"kind": "detail",
|
|
361
361
|
"markdown": "In **Settings → Apps → your app → PostHog**, set the account-created event and the first UTC date from which tracking is complete.\n\n| Metric | What counts |\n|---|---|\n| Signups | First `sprid_signup` event per PostHog person, across all surfaces |\n| New product users | First product activity, including anonymous people |\n| Active product users | Distinct people with product activity in the period |\n| Web visitors | Distinct pageview visitors on the saved website hostname, minus your product |\n\n**Signups.** Send `sprid_signup` from your server after the account is created, with the account id as `distinct_id`, and identify that id in your clients. Never fire it on login, page load or install. An existing `posthogEvents.signup` mapping also works; `posthogConfig.registration.event` wins over it. With no tracking start date, no matching event history reads as unavailable; with one, it reads as zero signups. A period that starts before the tracking date is counted from that date and says so. Comparisons crossing it, or with no confirmed start, are withheld. Backfill with the original creation timestamps.\n\n**Every signup surface has to send it.** An event your app sends after sign-up misses accounts made on your website, and the other way round: one app's in-app event missed 17% of its signups, which happened on its website. A server event covers every surface at once. Without one, list each surface's account-created event in `registration.events`; the first of any of them per identity counts, so an existing account signing in elsewhere is not a new signup. Never add a sign-in event that existing accounts also send. The marketing review flags a signup-like event on a surface where your registration event never fires.\n\nWhen an app reports signups, they are its user count on Home and in Insights. First product activity counts anonymous devices, so it moves into that card's detail.\n\n### What counts as your product\n\nSet this under **What counts as your product** on the same screen. It decides who is an active person, and it is the single setting most likely to make the people card wrong.\n\n| Your product is | Choose | Also give |\n|---|---|---|\n| An iOS or Android app | **A phone app** | Nothing. This is the default |\n| A web app on its own subdomain | **A website or web app** | The hostnames, such as `app.example.com` |\n| A web app under a path on your marketing domain | **A website or web app** | The paths, such as `/app`, `/dashboard` |\n| A phone app with a web client | **Both** | The web hostnames or paths |\n\n**A web product must say where it lives.** Your marketing pages and your product are the same PostHog events; without a hostname or a path there is nothing to tell them apart, and every visitor would be counted as someone using the product. Sprid refuses that configuration rather than reporting it.\n\nWhatever you name here is **subtracted from your website visitor numbers**, so a page inside the product is never also counted as a visit.\n\n**A phone app** needs `posthog-react-native` on iOS, iPadOS or Android and rejects explicit web surfaces: set `app_surface: 'web'` on Expo web and `'native'` on devices. Web counts need a browser and exclude reported bots and crawler user agents. Every metric excludes events or people with `is_internal`, `is_test` or `sprid_test = true`. What remains is observed identities, not guaranteed humans.\n\n### Keep your own testing out\n\nOn a new app, your own phone, simulators and store reviewers can outnumber real people. Sprid already drops Google Play's pre-launch test devices, events reporting `$is_emulator`, reported bots, and anything flagged `is_internal`, `is_test` or `sprid_test`. Two things are yours to add:\n\n- **Flag your own accounts.** After sign-in, register `is_internal: true` when the account's email is on your own domain, store-review accounts included, and set it on the person if you identify. A web client that never identifies has to put it on every event, before the first pageview. Do the same on the server event that records a signup.\n- **List your own devices** under `exclude`, for what happens before sign-in, such as a fresh install. Open your app, then read the newest events in PostHog for the `$device_name` values:\n\n```json\n{\"exclude\":[{\"scope\":\"event\",\"property\":\"$device_name\",\"operator\":\"in\",\"values\":[\"Pixel 8\",\"Simulator iOS\",\"sdk_gphone64_arm64\"]}]}\n```\n\nApple's review devices have reported `$device_name` `iPhone99,7`, with locale `en-US` and time zone `US/Pacific`, in every app we checked (September 2026). Add it if it shows up in yours.\n\nTwo more Apple populations pass every default rule, because they are real iPhones: the device that checks each uploaded build (a fresh US person per build, often Cupertino, locale a bare `en` where real phones send `en-US`) and App Review (US, locale `zh-Hans`, about one a day). Both are active for one day and never reach a paywall: one app counted 14 of them as installs in two months. The marketing review flags them with their counts. Exclude a locale only after checking none of your real users send it.\n\nGoogle Play's pre-launch devices are covered by default: in the app we checked, every `OnePlus8Pro` event carried the fleet's screen width and every `sdk_gphone` event carried `$is_emulator` (2026-10-03). If yours sends neither property, list the device names under `exclude`.\n\n### Custom properties\n\nAdvanced rules go under **Custom properties**:\n\n```json\n{\"registration\":{\"identity\":{\"scope\":\"event\",\"property\":\"account_id\"}},\"app\":{\"kind\":\"web\",\"hosts\":[\"app.example.com\"]},\"exclude\":[{\"scope\":\"person\",\"property\":\"staff\",\"operator\":\"in\",\"values\":[true]}]}\n```\n\n- `app.kind` is `native`, `web` or `both`, with `app.hosts` and `app.pathPrefixes` saying where a web product lives. This is what the screen above writes.\n- `app.filters` replaces the whole definition with your own rule; all filters must match. Choosing it shows as **Custom rules** on the screen, and the rule is subtracted from web visitors the same way.\n- `exclude` removes matching traffic from every metric.\n- `registration.filters` narrows the registration event, for example `result = success`.\n- `registration.events` adds account-created events from other surfaces, counted with `registration.event` (see Signups above).\n- `entryUrls` lists the pages a plan, campaign or bio link sends people to, such as a web funnel's first step. The review flags one with almost no pageviews in 14 days, which usually means a link stopped pointing at it.\n- `registration.accounts: false` says the app has no accounts (a local-first app, or one that identifies by device). Registrations then read as not applicable instead of a missing event, and the weekly email stops listing them as unread.\n- `registration.identity` defaults to `person_id`. A configured property must be present and should never change, because it counts accounts.\n- Each rule takes `scope: event|person`, `operator: in|not_in` and string, number or boolean `values`. A missing property fails `in` and passes `not_in`. Property names are literal keys, dots included.\n\nThe same object is `posthogConfig` in REST `PATCH /api/app-profiles/:id`, MCP `upsert_app_profile` and the App Profile JSON used by `sprid init`, where `registration.event` and `registration.since` also live. No credentials or SQL belong in it. After saving, fetch Insights and check its registration notes and totals against your database before trusting a funnel.\n\n### Paywall and purchase events\n\nA paywall event that says only that a paywall appeared cannot tell a price change from a paywall that failed to load: both read as fewer sales. One app's Android paywall had no purchasable product for five months and its revenue view read as zero demand. Send at least these, and map the events under `posthogEvents` as `paywallShown` and `purchase`:\n\n| Event | Properties |\n|---|---|\n| Paywall shown | `price`, `currency`, `product_id`, `offering_id`, `offering_loaded` (true once the store returned a product), `has_free_phase`, `store_country` |\n| Purchase or trial started, after the store confirms it | `price`, `currency`, `product_id`, `has_free_phase` |\n| Purchase failed | `product_id`, the store's error code |\n\nReport a billing error to your error tracker too, never only to the console. The marketing review flags a platform that shows the paywall to many people and never sells, and paywall events that carry none of these properties."
|
|
362
362
|
},
|
|
363
|
+
{
|
|
364
|
+
"id": "countries-sources-and-entry-pages",
|
|
365
|
+
"title": "Countries, sources and entry pages",
|
|
366
|
+
"kind": "detail",
|
|
367
|
+
"markdown": "Each website breakdown is one PostHog property on each `$pageview`. posthog-js fills them all itself, so a site that loads it with default settings needs nothing here. They go missing when something else sends the events, and the breakdown then reads **Unknown**. Insights and `get_insights` (`traffic.gaps`) name which one is missing, why, and the fix.\n\n| Breakdown | Property Sprid reads | posthog-js sets it from |\n|---|---|---|\n| Country, region, city | `$geoip_country_code`, `$geoip_subdivision_1_name`, `$geoip_city_name` | PostHog’s GeoIP lookup of the visitor’s IP |\n| Source | `$referring_domain` | The referrer’s hostname, or `$direct` when there is none, on every page of the visit |\n| Entry and exit page, sessions, bounce, time on site | `$session_id` | One id per visit |\n| Browser, OS, device | `$browser`, `$os`, `$device_type` | The user agent |\n| Pages, hostname | `$pathname`, `$host` | The page address |\n\nWebsite events also need `$lib: \"web\"`; Sprid tells your site apart from your app by it.\n\nThree setups lose the country, and each has its own fix:\n\n- **Cookieless mode** (`cookieless_mode: \"always\"`): PostHog does not geolocate cookieless events, so every country is Unknown. Register the country yourself before the first pageview, from a value your host already has. On Cloudflare that is `request.cf.country`, the `CF-IPCountry` header, or `loc=` in `/cdn-cgi/trace` on your own domain: `posthog.register({ $geoip_country_code: \"SE\" })`. Or turn cookieless mode off if your privacy policy allows it.\n- **Your own relay or server** (a Worker, an API route or a proxy that calls PostHog’s capture endpoint): PostHog sees the server’s IP, or none if you set `$geoip_disable: true`. Set `$geoip_country_code` in the relay. A property called `country` does not count: nothing in PostHog or Sprid reads it.\n- **\"Discard client IP data\"** under Project settings, or the GeoIP transformation switched off under Data pipelines: no event in the project gets a location. Turn it back on, or supply the country as above.\n\nA relay also has to send what posthog-js would have: `$referring_domain` on every pageview (`$direct` when the visit had no referrer, and the landing page’s referrer on every later page of the same visit), and a `$session_id` that stays the same for the whole visit. For example, in a Cloudflare Worker:\n\n```js\nproperties: {\n ...props,\n $lib: \"web\",\n $host: \"example.com\",\n $pathname: path,\n $session_id: visitId,\n $referring_domain: visitReferrer || \"$direct\",\n $geoip_disable: true, // PostHog never sees the IP\n $geoip_country_code: request.cf?.country ?? null,\n}\n```\n\nA visit whose source is `localhost` is someone on a development build clicking through to your live site. Stop capturing on development hosts, or exclude it as below."
|
|
368
|
+
},
|
|
363
369
|
{
|
|
364
370
|
"id": "review-suspected-automated-traffic",
|
|
365
371
|
"title": "Review suspected automated traffic",
|
|
@@ -391,8 +397,8 @@ export const GUIDES = [
|
|
|
391
397
|
"markdown": "Setup and live reads checked 2026-09-09.\n\n- [PostHog personal API keys](https://posthog.com/docs/api/personal-api-keys)\n- [Project identity endpoint](https://posthog.com/docs/api/projects)\n- [HogQL query endpoint](https://posthog.com/docs/api/query)\n- [SDK and framework guides](https://posthog.com/docs/libraries)\n- [React Native screen tracking](https://posthog.com/docs/libraries/react-native)\n- [Identifying users](https://posthog.com/docs/product-analytics/identify)\n- [Funnels](https://posthog.com/docs/product-analytics/funnels)"
|
|
392
398
|
}
|
|
393
399
|
],
|
|
394
|
-
"markdown": "# PostHog\n\n**What Sprid does with this:** See how people use your app and where they stop.\n\n## You need\n\nAccess to your app’s PostHog project and permission to create a **personal API key**. Your app must already send events to PostHog; connecting Sprid adds no tracking. The public key inside your app does not work here.\n\n## Click path (us.posthog.com or eu.posthog.com)\n\n1. **Settings → Account → Personal API keys → Create personal API key**, named `Sprid <app name>`.\n2. Under access, select **Projects** and choose your app’s project. Leave **All access** off.\n3. Select **Query → Read** and **Project → Read**. Leave write access off.\n4. Create the key and keep the dialog open: PostHog shows it once. Use a separate key per app.\n5. Copy the key and leave it on your clipboard.\n\n## Project id and host\n\n- **Project id:** the number after `/project/` in your PostHog address, such as `12345`.\n- **Host:** `eu` for `eu.posthog.com`, `us` for `us.posthog.com`, or your full self-hosted address, whichever you open the dashboard on.\n\n## Then run\n\n```sh\nsprid connect posthog --app myapp --key-from-clipboard --project 12345 --host eu\n```\n\nUse your own app slug, project id and host. Add `--workspace <slug>` if needed. Keep the key out of chat.\n\nSprid reads the key from your clipboard, saves it and clears the clipboard, so it never shows on screen or lands in a file. Working with an agent? Tell it the key is copied and it runs this for you. Typing it yourself, copy the key last: paste the command into your terminal first, then copy the key, then press Enter. If the clipboard still holds the command, Sprid refuses it; copy the key and run it again. Without clipboard access (a remote shell), save the key to a file and pass `--key <file>` instead.\n\n## How to check it worked\n\nAsk your agent: “Check that Sprid can read recent PostHog events for this app, and confirm the project is correct.” The check reports recent activity or explains why there is none; a saved connection alone does not prove events arrive.\n\n## Metric definitions\n\nIn **Settings → Apps → your app → PostHog**, set the account-created event and the first UTC date from which tracking is complete.\n\n| Metric | What counts |\n|---|---|\n| Signups | First `sprid_signup` event per PostHog person, across all surfaces |\n| New product users | First product activity, including anonymous people |\n| Active product users | Distinct people with product activity in the period |\n| Web visitors | Distinct pageview visitors on the saved website hostname, minus your product |\n\n**Signups.** Send `sprid_signup` from your server after the account is created, with the account id as `distinct_id`, and identify that id in your clients. Never fire it on login, page load or install. An existing `posthogEvents.signup` mapping also works; `posthogConfig.registration.event` wins over it. With no tracking start date, no matching event history reads as unavailable; with one, it reads as zero signups. A period that starts before the tracking date is counted from that date and says so. Comparisons crossing it, or with no confirmed start, are withheld. Backfill with the original creation timestamps.\n\n**Every signup surface has to send it.** An event your app sends after sign-up misses accounts made on your website, and the other way round: one app's in-app event missed 17% of its signups, which happened on its website. A server event covers every surface at once. Without one, list each surface's account-created event in `registration.events`; the first of any of them per identity counts, so an existing account signing in elsewhere is not a new signup. Never add a sign-in event that existing accounts also send. The marketing review flags a signup-like event on a surface where your registration event never fires.\n\nWhen an app reports signups, they are its user count on Home and in Insights. First product activity counts anonymous devices, so it moves into that card's detail.\n\n### What counts as your product\n\nSet this under **What counts as your product** on the same screen. It decides who is an active person, and it is the single setting most likely to make the people card wrong.\n\n| Your product is | Choose | Also give |\n|---|---|---|\n| An iOS or Android app | **A phone app** | Nothing. This is the default |\n| A web app on its own subdomain | **A website or web app** | The hostnames, such as `app.example.com` |\n| A web app under a path on your marketing domain | **A website or web app** | The paths, such as `/app`, `/dashboard` |\n| A phone app with a web client | **Both** | The web hostnames or paths |\n\n**A web product must say where it lives.** Your marketing pages and your product are the same PostHog events; without a hostname or a path there is nothing to tell them apart, and every visitor would be counted as someone using the product. Sprid refuses that configuration rather than reporting it.\n\nWhatever you name here is **subtracted from your website visitor numbers**, so a page inside the product is never also counted as a visit.\n\n**A phone app** needs `posthog-react-native` on iOS, iPadOS or Android and rejects explicit web surfaces: set `app_surface: 'web'` on Expo web and `'native'` on devices. Web counts need a browser and exclude reported bots and crawler user agents. Every metric excludes events or people with `is_internal`, `is_test` or `sprid_test = true`. What remains is observed identities, not guaranteed humans.\n\n### Keep your own testing out\n\nOn a new app, your own phone, simulators and store reviewers can outnumber real people. Sprid already drops Google Play's pre-launch test devices, events reporting `$is_emulator`, reported bots, and anything flagged `is_internal`, `is_test` or `sprid_test`. Two things are yours to add:\n\n- **Flag your own accounts.** After sign-in, register `is_internal: true` when the account's email is on your own domain, store-review accounts included, and set it on the person if you identify. A web client that never identifies has to put it on every event, before the first pageview. Do the same on the server event that records a signup.\n- **List your own devices** under `exclude`, for what happens before sign-in, such as a fresh install. Open your app, then read the newest events in PostHog for the `$device_name` values:\n\n```json\n{\"exclude\":[{\"scope\":\"event\",\"property\":\"$device_name\",\"operator\":\"in\",\"values\":[\"Pixel 8\",\"Simulator iOS\",\"sdk_gphone64_arm64\"]}]}\n```\n\nApple's review devices have reported `$device_name` `iPhone99,7`, with locale `en-US` and time zone `US/Pacific`, in every app we checked (September 2026). Add it if it shows up in yours.\n\nTwo more Apple populations pass every default rule, because they are real iPhones: the device that checks each uploaded build (a fresh US person per build, often Cupertino, locale a bare `en` where real phones send `en-US`) and App Review (US, locale `zh-Hans`, about one a day). Both are active for one day and never reach a paywall: one app counted 14 of them as installs in two months. The marketing review flags them with their counts. Exclude a locale only after checking none of your real users send it.\n\nGoogle Play's pre-launch devices are covered by default: in the app we checked, every `OnePlus8Pro` event carried the fleet's screen width and every `sdk_gphone` event carried `$is_emulator` (2026-10-03). If yours sends neither property, list the device names under `exclude`.\n\n### Custom properties\n\nAdvanced rules go under **Custom properties**:\n\n```json\n{\"registration\":{\"identity\":{\"scope\":\"event\",\"property\":\"account_id\"}},\"app\":{\"kind\":\"web\",\"hosts\":[\"app.example.com\"]},\"exclude\":[{\"scope\":\"person\",\"property\":\"staff\",\"operator\":\"in\",\"values\":[true]}]}\n```\n\n- `app.kind` is `native`, `web` or `both`, with `app.hosts` and `app.pathPrefixes` saying where a web product lives. This is what the screen above writes.\n- `app.filters` replaces the whole definition with your own rule; all filters must match. Choosing it shows as **Custom rules** on the screen, and the rule is subtracted from web visitors the same way.\n- `exclude` removes matching traffic from every metric.\n- `registration.filters` narrows the registration event, for example `result = success`.\n- `registration.events` adds account-created events from other surfaces, counted with `registration.event` (see Signups above).\n- `entryUrls` lists the pages a plan, campaign or bio link sends people to, such as a web funnel's first step. The review flags one with almost no pageviews in 14 days, which usually means a link stopped pointing at it.\n- `registration.accounts: false` says the app has no accounts (a local-first app, or one that identifies by device). Registrations then read as not applicable instead of a missing event, and the weekly email stops listing them as unread.\n- `registration.identity` defaults to `person_id`. A configured property must be present and should never change, because it counts accounts.\n- Each rule takes `scope: event|person`, `operator: in|not_in` and string, number or boolean `values`. A missing property fails `in` and passes `not_in`. Property names are literal keys, dots included.\n\nThe same object is `posthogConfig` in REST `PATCH /api/app-profiles/:id`, MCP `upsert_app_profile` and the App Profile JSON used by `sprid init`, where `registration.event` and `registration.since` also live. No credentials or SQL belong in it. After saving, fetch Insights and check its registration notes and totals against your database before trusting a funnel.\n\n### Paywall and purchase events\n\nA paywall event that says only that a paywall appeared cannot tell a price change from a paywall that failed to load: both read as fewer sales. One app's Android paywall had no purchasable product for five months and its revenue view read as zero demand. Send at least these, and map the events under `posthogEvents` as `paywallShown` and `purchase`:\n\n| Event | Properties |\n|---|---|\n| Paywall shown | `price`, `currency`, `product_id`, `offering_id`, `offering_loaded` (true once the store returned a product), `has_free_phase`, `store_country` |\n| Purchase or trial started, after the store confirms it | `price`, `currency`, `product_id`, `has_free_phase` |\n| Purchase failed | `product_id`, the store's error code |\n\nReport a billing error to your error tracker too, never only to the console. The marketing review flags a platform that shows the paywall to many people and never sells, and paywall events that carry none of these properties.\n\n## Review suspected automated traffic\n\nRead [Check whether a traffic spike is real](https://sprid.studio/docs/traffic) (`sprid docs traffic`) before saving a rule: a spike or a shared fingerprint alone does not prove bots.\n\nNot every crawler arrives as a spike. One site's September was 84% people on desktop, with no referrer, from one country, most of them for one pageview, with a normal Chrome user agent that no bot filter catches. The marketing review flags a desktop, direct, single-country group above half of website people; confirm it here before excluding anything.\n\nOpen **Web visitors → Review traffic exclusions**, or **Settings → Apps → your app → PostHog → Review traffic**. Pick a UTC date range and a traffic group, preview the effect, then save. Agents use MCP `review_traffic` or `POST /api/app-profiles/:ref/traffic-review?workspaceId=…` with `{\"start\":\"2026-09-18\",\"end\":\"2026-09-19\"}`: end dates exclusive, UTC, at most 31 days. An optional `exclusion` previews a rule against this period and the one before. Save approved rules in `posthogConfig.trafficExclusions` through `upsert_app_profile` or the profile PATCH route, keeping the rest of the configuration. Sprid records who edited it and when.\n\nHow exclusions apply:\n\n- App-specific and date-bounded. Properties inside a rule are AND; enabled rules are OR.\n- Applied to website totals, charts, breakdowns, social attribution and weekly website counts, comparison period included. Remaining visitors are recounted as distinct people, never subtracted.\n- Native app activity and registrations are unchanged, and PostHog’s data is never changed or deleted. **Restore this traffic** disables a rule.\n- Raw connected queries and the marketing-review event inventory stay unfiltered as evidence; Insights shows the corrected figures.\n- Only PostHog is supported today.\n\n## Social traffic and clip comparisons\n\nSave the app’s website URL on its App Profile as well as connecting PostHog. Insights then compares completed-day website sessions with recorded social view gains. Shared bio traffic stays at channel level; only a dedicated tagged link identifies one publish. Older clips with metric activity stay candidates.\n\nCopy the stable bio link and dedicated clip links from Insights. Keep `utm_source`, `utm_medium`, `sprid_account` and, on dedicated links only, `sprid_publish` through any redirects. Never repoint the shared bio link to the newest publish. The optional bio-page HTML export records selections separately and keeps a direct app link; host it on the saved hostname with your existing PostHog setup.\n\nTo capture store-link clicks and website outcomes, add after your PostHog initialization:\n\n```html\n<script src=\"https://sprid.studio/sprid-attribution.js\" defer></script>\n```\n\nIt uses your PostHog client and consent state and sends nothing to Sprid. Call `window.spridAttribution?.track('signup')` or `.track('activation')` only after that action succeeds. `posthogEvents.signup` and `posthogEvents.activation` mappings also count when the event shares the arriving website session. It does not track a native app or join visits to purchases, and a store click is not an install. Missing outcomes stay unmeasured. Redirect-only flows need a beacon before navigating; redirect events are counted apart from sessions. `get_insights` and `sprid insights` return the same evidence as the dashboard.\n\n## If it fails\n\n- **Access denied:** check the key is active, grants your project and has both Read permissions. Reconnect with a corrected key file.\n- **Project not found:** check project number and host together. A US project needs the US host.\n- **Connected but no events:** check the dates and project, then ask your agent to check your app’s tracking is sending events.\n\n## Investigate with this connection\n\nReads `query` (HogQL), `events` and `properties` for the pinned project. Check instrumentation before interpreting events. Event-definition discovery also needs `event_definition:read`, property-definition discovery `property_definition:read`, both restricted to the same project.\n\n```sh\nsprid marketing-review capabilities --app <slug> --source posthog --json\n```\n\nSee [connected queries](https://sprid.studio/docs/queries).\n\n## Sources and verification\n\nSetup and live reads checked 2026-09-09.\n\n- [PostHog personal API keys](https://posthog.com/docs/api/personal-api-keys)\n- [Project identity endpoint](https://posthog.com/docs/api/projects)\n- [HogQL query endpoint](https://posthog.com/docs/api/query)\n- [SDK and framework guides](https://posthog.com/docs/libraries)\n- [React Native screen tracking](https://posthog.com/docs/libraries/react-native)\n- [Identifying users](https://posthog.com/docs/product-analytics/identify)\n- [Funnels](https://posthog.com/docs/product-analytics/funnels)\n",
|
|
395
|
-
"revision": "
|
|
400
|
+
"markdown": "# PostHog\n\n**What Sprid does with this:** See how people use your app and where they stop.\n\n## You need\n\nAccess to your app’s PostHog project and permission to create a **personal API key**. Your app must already send events to PostHog; connecting Sprid adds no tracking. The public key inside your app does not work here.\n\n## Click path (us.posthog.com or eu.posthog.com)\n\n1. **Settings → Account → Personal API keys → Create personal API key**, named `Sprid <app name>`.\n2. Under access, select **Projects** and choose your app’s project. Leave **All access** off.\n3. Select **Query → Read** and **Project → Read**. Leave write access off.\n4. Create the key and keep the dialog open: PostHog shows it once. Use a separate key per app.\n5. Copy the key and leave it on your clipboard.\n\n## Project id and host\n\n- **Project id:** the number after `/project/` in your PostHog address, such as `12345`.\n- **Host:** `eu` for `eu.posthog.com`, `us` for `us.posthog.com`, or your full self-hosted address, whichever you open the dashboard on.\n\n## Then run\n\n```sh\nsprid connect posthog --app myapp --key-from-clipboard --project 12345 --host eu\n```\n\nUse your own app slug, project id and host. Add `--workspace <slug>` if needed. Keep the key out of chat.\n\nSprid reads the key from your clipboard, saves it and clears the clipboard, so it never shows on screen or lands in a file. Working with an agent? Tell it the key is copied and it runs this for you. Typing it yourself, copy the key last: paste the command into your terminal first, then copy the key, then press Enter. If the clipboard still holds the command, Sprid refuses it; copy the key and run it again. Without clipboard access (a remote shell), save the key to a file and pass `--key <file>` instead.\n\n## How to check it worked\n\nAsk your agent: “Check that Sprid can read recent PostHog events for this app, and confirm the project is correct.” The check reports recent activity or explains why there is none; a saved connection alone does not prove events arrive.\n\n## Metric definitions\n\nIn **Settings → Apps → your app → PostHog**, set the account-created event and the first UTC date from which tracking is complete.\n\n| Metric | What counts |\n|---|---|\n| Signups | First `sprid_signup` event per PostHog person, across all surfaces |\n| New product users | First product activity, including anonymous people |\n| Active product users | Distinct people with product activity in the period |\n| Web visitors | Distinct pageview visitors on the saved website hostname, minus your product |\n\n**Signups.** Send `sprid_signup` from your server after the account is created, with the account id as `distinct_id`, and identify that id in your clients. Never fire it on login, page load or install. An existing `posthogEvents.signup` mapping also works; `posthogConfig.registration.event` wins over it. With no tracking start date, no matching event history reads as unavailable; with one, it reads as zero signups. A period that starts before the tracking date is counted from that date and says so. Comparisons crossing it, or with no confirmed start, are withheld. Backfill with the original creation timestamps.\n\n**Every signup surface has to send it.** An event your app sends after sign-up misses accounts made on your website, and the other way round: one app's in-app event missed 17% of its signups, which happened on its website. A server event covers every surface at once. Without one, list each surface's account-created event in `registration.events`; the first of any of them per identity counts, so an existing account signing in elsewhere is not a new signup. Never add a sign-in event that existing accounts also send. The marketing review flags a signup-like event on a surface where your registration event never fires.\n\nWhen an app reports signups, they are its user count on Home and in Insights. First product activity counts anonymous devices, so it moves into that card's detail.\n\n### What counts as your product\n\nSet this under **What counts as your product** on the same screen. It decides who is an active person, and it is the single setting most likely to make the people card wrong.\n\n| Your product is | Choose | Also give |\n|---|---|---|\n| An iOS or Android app | **A phone app** | Nothing. This is the default |\n| A web app on its own subdomain | **A website or web app** | The hostnames, such as `app.example.com` |\n| A web app under a path on your marketing domain | **A website or web app** | The paths, such as `/app`, `/dashboard` |\n| A phone app with a web client | **Both** | The web hostnames or paths |\n\n**A web product must say where it lives.** Your marketing pages and your product are the same PostHog events; without a hostname or a path there is nothing to tell them apart, and every visitor would be counted as someone using the product. Sprid refuses that configuration rather than reporting it.\n\nWhatever you name here is **subtracted from your website visitor numbers**, so a page inside the product is never also counted as a visit.\n\n**A phone app** needs `posthog-react-native` on iOS, iPadOS or Android and rejects explicit web surfaces: set `app_surface: 'web'` on Expo web and `'native'` on devices. Web counts need a browser and exclude reported bots and crawler user agents. Every metric excludes events or people with `is_internal`, `is_test` or `sprid_test = true`. What remains is observed identities, not guaranteed humans.\n\n### Keep your own testing out\n\nOn a new app, your own phone, simulators and store reviewers can outnumber real people. Sprid already drops Google Play's pre-launch test devices, events reporting `$is_emulator`, reported bots, and anything flagged `is_internal`, `is_test` or `sprid_test`. Two things are yours to add:\n\n- **Flag your own accounts.** After sign-in, register `is_internal: true` when the account's email is on your own domain, store-review accounts included, and set it on the person if you identify. A web client that never identifies has to put it on every event, before the first pageview. Do the same on the server event that records a signup.\n- **List your own devices** under `exclude`, for what happens before sign-in, such as a fresh install. Open your app, then read the newest events in PostHog for the `$device_name` values:\n\n```json\n{\"exclude\":[{\"scope\":\"event\",\"property\":\"$device_name\",\"operator\":\"in\",\"values\":[\"Pixel 8\",\"Simulator iOS\",\"sdk_gphone64_arm64\"]}]}\n```\n\nApple's review devices have reported `$device_name` `iPhone99,7`, with locale `en-US` and time zone `US/Pacific`, in every app we checked (September 2026). Add it if it shows up in yours.\n\nTwo more Apple populations pass every default rule, because they are real iPhones: the device that checks each uploaded build (a fresh US person per build, often Cupertino, locale a bare `en` where real phones send `en-US`) and App Review (US, locale `zh-Hans`, about one a day). Both are active for one day and never reach a paywall: one app counted 14 of them as installs in two months. The marketing review flags them with their counts. Exclude a locale only after checking none of your real users send it.\n\nGoogle Play's pre-launch devices are covered by default: in the app we checked, every `OnePlus8Pro` event carried the fleet's screen width and every `sdk_gphone` event carried `$is_emulator` (2026-10-03). If yours sends neither property, list the device names under `exclude`.\n\n### Custom properties\n\nAdvanced rules go under **Custom properties**:\n\n```json\n{\"registration\":{\"identity\":{\"scope\":\"event\",\"property\":\"account_id\"}},\"app\":{\"kind\":\"web\",\"hosts\":[\"app.example.com\"]},\"exclude\":[{\"scope\":\"person\",\"property\":\"staff\",\"operator\":\"in\",\"values\":[true]}]}\n```\n\n- `app.kind` is `native`, `web` or `both`, with `app.hosts` and `app.pathPrefixes` saying where a web product lives. This is what the screen above writes.\n- `app.filters` replaces the whole definition with your own rule; all filters must match. Choosing it shows as **Custom rules** on the screen, and the rule is subtracted from web visitors the same way.\n- `exclude` removes matching traffic from every metric.\n- `registration.filters` narrows the registration event, for example `result = success`.\n- `registration.events` adds account-created events from other surfaces, counted with `registration.event` (see Signups above).\n- `entryUrls` lists the pages a plan, campaign or bio link sends people to, such as a web funnel's first step. The review flags one with almost no pageviews in 14 days, which usually means a link stopped pointing at it.\n- `registration.accounts: false` says the app has no accounts (a local-first app, or one that identifies by device). Registrations then read as not applicable instead of a missing event, and the weekly email stops listing them as unread.\n- `registration.identity` defaults to `person_id`. A configured property must be present and should never change, because it counts accounts.\n- Each rule takes `scope: event|person`, `operator: in|not_in` and string, number or boolean `values`. A missing property fails `in` and passes `not_in`. Property names are literal keys, dots included.\n\nThe same object is `posthogConfig` in REST `PATCH /api/app-profiles/:id`, MCP `upsert_app_profile` and the App Profile JSON used by `sprid init`, where `registration.event` and `registration.since` also live. No credentials or SQL belong in it. After saving, fetch Insights and check its registration notes and totals against your database before trusting a funnel.\n\n### Paywall and purchase events\n\nA paywall event that says only that a paywall appeared cannot tell a price change from a paywall that failed to load: both read as fewer sales. One app's Android paywall had no purchasable product for five months and its revenue view read as zero demand. Send at least these, and map the events under `posthogEvents` as `paywallShown` and `purchase`:\n\n| Event | Properties |\n|---|---|\n| Paywall shown | `price`, `currency`, `product_id`, `offering_id`, `offering_loaded` (true once the store returned a product), `has_free_phase`, `store_country` |\n| Purchase or trial started, after the store confirms it | `price`, `currency`, `product_id`, `has_free_phase` |\n| Purchase failed | `product_id`, the store's error code |\n\nReport a billing error to your error tracker too, never only to the console. The marketing review flags a platform that shows the paywall to many people and never sells, and paywall events that carry none of these properties.\n\n## Countries, sources and entry pages\n\nEach website breakdown is one PostHog property on each `$pageview`. posthog-js fills them all itself, so a site that loads it with default settings needs nothing here. They go missing when something else sends the events, and the breakdown then reads **Unknown**. Insights and `get_insights` (`traffic.gaps`) name which one is missing, why, and the fix.\n\n| Breakdown | Property Sprid reads | posthog-js sets it from |\n|---|---|---|\n| Country, region, city | `$geoip_country_code`, `$geoip_subdivision_1_name`, `$geoip_city_name` | PostHog’s GeoIP lookup of the visitor’s IP |\n| Source | `$referring_domain` | The referrer’s hostname, or `$direct` when there is none, on every page of the visit |\n| Entry and exit page, sessions, bounce, time on site | `$session_id` | One id per visit |\n| Browser, OS, device | `$browser`, `$os`, `$device_type` | The user agent |\n| Pages, hostname | `$pathname`, `$host` | The page address |\n\nWebsite events also need `$lib: \"web\"`; Sprid tells your site apart from your app by it.\n\nThree setups lose the country, and each has its own fix:\n\n- **Cookieless mode** (`cookieless_mode: \"always\"`): PostHog does not geolocate cookieless events, so every country is Unknown. Register the country yourself before the first pageview, from a value your host already has. On Cloudflare that is `request.cf.country`, the `CF-IPCountry` header, or `loc=` in `/cdn-cgi/trace` on your own domain: `posthog.register({ $geoip_country_code: \"SE\" })`. Or turn cookieless mode off if your privacy policy allows it.\n- **Your own relay or server** (a Worker, an API route or a proxy that calls PostHog’s capture endpoint): PostHog sees the server’s IP, or none if you set `$geoip_disable: true`. Set `$geoip_country_code` in the relay. A property called `country` does not count: nothing in PostHog or Sprid reads it.\n- **\"Discard client IP data\"** under Project settings, or the GeoIP transformation switched off under Data pipelines: no event in the project gets a location. Turn it back on, or supply the country as above.\n\nA relay also has to send what posthog-js would have: `$referring_domain` on every pageview (`$direct` when the visit had no referrer, and the landing page’s referrer on every later page of the same visit), and a `$session_id` that stays the same for the whole visit. For example, in a Cloudflare Worker:\n\n```js\nproperties: {\n ...props,\n $lib: \"web\",\n $host: \"example.com\",\n $pathname: path,\n $session_id: visitId,\n $referring_domain: visitReferrer || \"$direct\",\n $geoip_disable: true, // PostHog never sees the IP\n $geoip_country_code: request.cf?.country ?? null,\n}\n```\n\nA visit whose source is `localhost` is someone on a development build clicking through to your live site. Stop capturing on development hosts, or exclude it as below.\n\n## Review suspected automated traffic\n\nRead [Check whether a traffic spike is real](https://sprid.studio/docs/traffic) (`sprid docs traffic`) before saving a rule: a spike or a shared fingerprint alone does not prove bots.\n\nNot every crawler arrives as a spike. One site's September was 84% people on desktop, with no referrer, from one country, most of them for one pageview, with a normal Chrome user agent that no bot filter catches. The marketing review flags a desktop, direct, single-country group above half of website people; confirm it here before excluding anything.\n\nOpen **Web visitors → Review traffic exclusions**, or **Settings → Apps → your app → PostHog → Review traffic**. Pick a UTC date range and a traffic group, preview the effect, then save. Agents use MCP `review_traffic` or `POST /api/app-profiles/:ref/traffic-review?workspaceId=…` with `{\"start\":\"2026-09-18\",\"end\":\"2026-09-19\"}`: end dates exclusive, UTC, at most 31 days. An optional `exclusion` previews a rule against this period and the one before. Save approved rules in `posthogConfig.trafficExclusions` through `upsert_app_profile` or the profile PATCH route, keeping the rest of the configuration. Sprid records who edited it and when.\n\nHow exclusions apply:\n\n- App-specific and date-bounded. Properties inside a rule are AND; enabled rules are OR.\n- Applied to website totals, charts, breakdowns, social attribution and weekly website counts, comparison period included. Remaining visitors are recounted as distinct people, never subtracted.\n- Native app activity and registrations are unchanged, and PostHog’s data is never changed or deleted. **Restore this traffic** disables a rule.\n- Raw connected queries and the marketing-review event inventory stay unfiltered as evidence; Insights shows the corrected figures.\n- Only PostHog is supported today.\n\n## Social traffic and clip comparisons\n\nSave the app’s website URL on its App Profile as well as connecting PostHog. Insights then compares completed-day website sessions with recorded social view gains. Shared bio traffic stays at channel level; only a dedicated tagged link identifies one publish. Older clips with metric activity stay candidates.\n\nCopy the stable bio link and dedicated clip links from Insights. Keep `utm_source`, `utm_medium`, `sprid_account` and, on dedicated links only, `sprid_publish` through any redirects. Never repoint the shared bio link to the newest publish. The optional bio-page HTML export records selections separately and keeps a direct app link; host it on the saved hostname with your existing PostHog setup.\n\nTo capture store-link clicks and website outcomes, add after your PostHog initialization:\n\n```html\n<script src=\"https://sprid.studio/sprid-attribution.js\" defer></script>\n```\n\nIt uses your PostHog client and consent state and sends nothing to Sprid. Call `window.spridAttribution?.track('signup')` or `.track('activation')` only after that action succeeds. `posthogEvents.signup` and `posthogEvents.activation` mappings also count when the event shares the arriving website session. It does not track a native app or join visits to purchases, and a store click is not an install. Missing outcomes stay unmeasured. Redirect-only flows need a beacon before navigating; redirect events are counted apart from sessions. `get_insights` and `sprid insights` return the same evidence as the dashboard.\n\n## If it fails\n\n- **Access denied:** check the key is active, grants your project and has both Read permissions. Reconnect with a corrected key file.\n- **Project not found:** check project number and host together. A US project needs the US host.\n- **Connected but no events:** check the dates and project, then ask your agent to check your app’s tracking is sending events.\n\n## Investigate with this connection\n\nReads `query` (HogQL), `events` and `properties` for the pinned project. Check instrumentation before interpreting events. Event-definition discovery also needs `event_definition:read`, property-definition discovery `property_definition:read`, both restricted to the same project.\n\n```sh\nsprid marketing-review capabilities --app <slug> --source posthog --json\n```\n\nSee [connected queries](https://sprid.studio/docs/queries).\n\n## Sources and verification\n\nSetup and live reads checked 2026-09-09.\n\n- [PostHog personal API keys](https://posthog.com/docs/api/personal-api-keys)\n- [Project identity endpoint](https://posthog.com/docs/api/projects)\n- [HogQL query endpoint](https://posthog.com/docs/api/query)\n- [SDK and framework guides](https://posthog.com/docs/libraries)\n- [React Native screen tracking](https://posthog.com/docs/libraries/react-native)\n- [Identifying users](https://posthog.com/docs/product-analytics/identify)\n- [Funnels](https://posthog.com/docs/product-analytics/funnels)\n",
|
|
401
|
+
"revision": "a16dcc4e464cc8c2"
|
|
396
402
|
},
|
|
397
403
|
{
|
|
398
404
|
"id": "google-analytics",
|
|
@@ -1652,8 +1658,8 @@ export const CONTENT_GUIDES = [
|
|
|
1652
1658
|
"title": "Ads: pay to show more people what already worked",
|
|
1653
1659
|
"summary": "Explains how Sprid thinks about paid ads on Instagram, Facebook, YouTube and TikTok: when to spend, how a promotion runs from draft to result, and which numbers to believe along the way.",
|
|
1654
1660
|
"url": "https://sprid.studio/docs/ads",
|
|
1655
|
-
"markdown": "# Ads: pay to show more people what already worked\n\n**What this guide does:** Explains how Sprid thinks about paid ads on\nInstagram, Facebook, YouTube and TikTok: when to spend, how a promotion runs\nfrom draft to result, and which numbers to believe along the way.\n\nAds don't rescue a post nobody wanted. They amplify, and they amplify\nwhatever you give them, including a weak idea. So the order Sprid follows is\norganic first: publish, see which posts people actually watched and saved, and\nput money behind the one that already beat your normal. That post has passed\nthe hardest test for free.\n\n## What we believe\n\n**Promote proof, not hope.** A post that did twice your median has evidence\nbehind it. A brand-new creative has only an opinion. Start with the post, and\nmake new creatives once you know what the winning one had.\n\n**Nothing spends without you.** Everything Sprid or your agent prepares is\npaused.
|
|
1656
|
-
"revision": "
|
|
1661
|
+
"markdown": "# Ads: pay to show more people what already worked\n\n**What this guide does:** Explains how Sprid thinks about paid ads on\nInstagram, Facebook, YouTube and TikTok: when to spend, how a promotion runs\nfrom draft to result, and which numbers to believe along the way.\n\nAds don't rescue a post nobody wanted. They amplify, and they amplify\nwhatever you give them, including a weak idea. So the order Sprid follows is\norganic first: publish, see which posts people actually watched and saved, and\nput money behind the one that already beat your normal. That post has passed\nthe hardest test for free.\n\n## What we believe\n\n**Promote proof, not hope.** A post that did twice your median has evidence\nbehind it. A brand-new creative has only an opinion. Start with the post, and\nmake new creatives once you know what the winning one had.\n\n**Nothing spends without you.** Everything Sprid or your agent prepares is\npaused. Spending starts only when you press Launch or Confirm in the Sprid app,\non a screen that shows what will run, what it costs a day and for how long. An\nagent can prepare a campaign and send you that link. It can't start one or\nraise a budget itself.\n\n**Keep the words, vary the picture.** We read 3,549 live ads across five\nmarkets. Among advertisers running eight or more, the median was 2.9 ads per\nline of copy, and the largest wellness advertiser ran 109 ads on essentially one\nline. The ad library counts placements separately, so the exact ratio overstates,\nbut the shape holds: the people who keep paying hold the copy and change the\ncreative. A new version changes one thing, or the result teaches nothing.\n\n**Give it time to be readable.** Meta's delivery settles after roughly 50\noptimisation events in a week, and Google and TikTok need a similar learning\nperiod. Before that, a result is noise, and the most\ncommon way to waste money on ads is to stop an ad set before it has said\nanything, then decide the idea doesn't work. Sprid says whether a result is\nreadable before it says whether it worked.\n\n**Judge by what happens in your product.** Cost per install or subscriber comes\nfirst and impressions come last, because the order a report puts numbers in is\nthe order people believe them in. When your app reports who became a paying\ncustomer, that outranks anything the platform can see.\n\n## Two ways to start\n\n**Promote a post.** Pick a post already live, set a daily budget and an end\ndate, and confirm. The ad runs only where that post lives, through the network\nbehind it:\n\n| Post live on | Runs as | Paid by |\n|---|---|---|\n| Instagram | a Meta ad on Instagram only | your Meta ad account |\n| Facebook | a Meta ad on Facebook only | your Meta ad account |\n| YouTube | a Google Ads video campaign (YouTube, Shorts, Discover, Gmail) | your Google Ads account |\n| TikTok | a Spark Ad on TikTok | your TikTok ad account |\n\nInstagram needs one more thing: the Instagram account has to be linked to the\nFacebook Page your Meta ad account runs as, because that is how Meta runs an\nInstagram ad. Until it is, the Ads page shows Instagram as **Not linked**, and\ntapping it says what to do.\n\nSprid checks the ad account can pay before you choose a budget, the network\nreviews the ad, and you get an email when it goes live, is rejected (with the\nnetwork's reason) or ends (with what the money bought). The budget and end date\ncan change while it runs, from Sprid. A TikTok post also needs the creator's\npermission for that one post: a code the TikTok app makes under the post's Ad\nsettings, pasted into Sprid.\n\n**Run a test.** For finding out what to say, not just saying it louder. Tests\nrun on Meta. Every\ncreative is built from a named shape, such as the screen that shows the app's\nverdict about the reader, two columns where the reader does the arithmetic, or\none sourced number. The shape is what makes a result transferable: \"showing the\nverdict beat listing features\" is something you can build again, \"ad 7 beat\nad 3\" is not.\n\n## The checks before money moves\n\n- **The copy check.** Banned words, your own never-say list, invented urgency,\n and openings that name an identity (\"for anxious people\") instead of a moment\n (\"you reread the message four times\"). Warnings pass, errors block the launch.\n- **Regulated categories.** Housing, credit, employment and political ads must\n be declared. An undeclared one isn't quietly rejected, it's pulled with the\n ad account attached. Anything about where people live, what they can borrow or\n who gets hired counts, even when the product doesn't look like it.\n- **The payment method**, before a budget is chosen rather than after Meta has\n accepted the campaign and refused the ad.\n\n## Reading the result\n\nAsk for an ad set's verdict once a week and it answers three questions: did it\nwork, why, and what next.\nWhen a test ends, it writes down what the hypothesis was, what happened and\nwhich shape carried it, so the next test starts from that instead of from zero.\nA losing set is data: close it and say what it ruled out.\n\nTwo rules for the numbers. A campaign's totals and its ad sets' totals are\nseparate readings of the same money, never added together. And a period that\nends today and one that ended last week are two different readings, so every\nnumber says which window it covers.\n\n## What competitors can and can't tell you\n\nMeta's Ad Library shows every active ad: the creative, the copy and the date it\nstarted. It shows no spend, no clicks and no results. Days live means somebody\nkeeps paying, not that it works, so a competitor's ad is \"longest-running\",\nnever \"best-performing\". Sprid's scout research reads the library for your own\nmarket, which is where the shapes above came from.\n\n## You need\n\n- An ad account with a card on it, connected to Sprid, for the network behind\n the post: [Meta Ads](https://sprid.studio/docs/connect/meta-ads) for\n Instagram and Facebook, [Google Ads](https://sprid.studio/docs/connect/google-ads)\n for YouTube, [TikTok Ads](https://sprid.studio/docs/connect/tiktok-ads) for\n TikTok.\n- A published post to promote, or, on Meta, an image or video to build a\n creative from.\n- You, signed in to the Sprid app, to press Launch. No agent or token can.\n",
|
|
1662
|
+
"revision": "89038f9560d6a887"
|
|
1657
1663
|
},
|
|
1658
1664
|
{
|
|
1659
1665
|
"id": "reel-first-seconds",
|