diff --git a/docs/operations/README.md b/docs/operations/README.md
index bada50ed..03542065 100644
--- a/docs/operations/README.md
+++ b/docs/operations/README.md
@@ -9,6 +9,6 @@ Documents that exist for the people running AnythingMCP in production rather tha
| [`slo.md`](./slo.md) | Service level objectives + how we measure them. |
| [`observability.md`](./observability.md) | Logs (always on), Sentry (opt-in), OpenTelemetry (opt-in), and the cloud host's uptime probe and weekly stuck-users report. |
| [`signup-attribution.md`](./signup-attribution.md) | Cloud: which channel brings sign-ups, verified sign-ups and paying customers (SQL), and the `sign_up_verified` GTM event. |
-| [`card-trial-offer.md`](./card-trial-offer.md) | Cloud: the card-trial offer after a workspace's first successful tool call (in-app and one email), its Stripe attribution (`metadata.trial_source`) and the weekly funnel (SQL + Stripe). |
+| [`card-trial-offer.md`](./card-trial-offer.md) | Cloud: the card-trial offer after a workspace's first successful tool call (in-app and one email), the checkout source every Cloud checkout carries to Stripe (`metadata.checkout_source`, per entry point) and the weekly funnel (SQL + Stripe). |
If you just installed AnythingMCP, read these in order.
diff --git a/docs/operations/card-trial-offer.md b/docs/operations/card-trial-offer.md
index d5d8e322..40d76c57 100644
--- a/docs/operations/card-trial-offer.md
+++ b/docs/operations/card-trial-offer.md
@@ -19,19 +19,38 @@ The licence site gives no card trial to an address that ever held a subscription
> Keep it running: start your trial, and My Shopify store keeps working after your free trial ends on 18 October 2026.
> [Not now] [Start your trial]
-The button opens `/start-trial?source=first_success`. "Not now" hides the card for three days on every page (localStorage, per user). Events: `card_trial_offer_shown` (once a day per user and browser), `card_trial_offer_clicked`, `card_trial_offer_dismissed`, each with `metadata.via` = `dashboard`, `connectors` or `connect`.
+The button opens `/start-trial?source=first_success_banner`. "Not now" hides the card for three days on every page (localStorage, per user). Events: `card_trial_offer_shown` (once a day per user and browser), `card_trial_offer_clicked`, `card_trial_offer_dismissed`, each with `metadata.via` = `dashboard`, `connectors` or `connect`.
-**Email.** The hourly Cloud onboarding cron runs `CardTrialOfferService.runEmailPass` after its trial-lifecycle and win-back passes. One email per workspace, to its active, verified admins who did not unsubscribe and whose address never had a subscription (without an answer from the licence site the email waits for the next run), between 30 minutes and 48 hours after it became eligible (30 to 90 minutes in practice). It is checked again at send time and is not sent once the workspace started a card trial, pays, or its trial ended. It waits while another email went out in the last 12 hours: a trial-lifecycle or win-back email to the workspace (`org_settings.trial_email_*`), or an onboarding or activation nudge to one of its admins (`users.onboarding_last_reminder_at`, `users.activation_reminder_at`). `org_settings.card_trial_offer_email` settles it: the send time, or `skipped:` (`customer`, `trial_ended`, `no_trial`, `no_recipient`, `no_trial_recipient`). A failed send is retried by the next run inside the window. Sent emails record `card_trial_offer_emailed` (`metadata.kind` = `trial`, or `plan` when under 48 hours were left and the email linked to the plans instead).
+**Email.** The hourly Cloud onboarding cron runs `CardTrialOfferService.runEmailPass` after its trial-lifecycle and win-back passes. One email per workspace, to its active, verified admins who did not unsubscribe and whose address never had a subscription (without an answer from the licence site the email waits for the next run), between 30 minutes and 48 hours after it became eligible (30 to 90 minutes in practice). It is checked again at send time and is not sent once the workspace started a card trial, pays, or its trial ended. It waits while another email went out in the last 12 hours: a trial-lifecycle or win-back email to the workspace (`org_settings.trial_email_*`), or an onboarding or activation nudge to one of its admins (`users.onboarding_last_reminder_at`, `users.activation_reminder_at`). `org_settings.card_trial_offer_email` settles it: the send time, or `skipped:` (`customer`, `trial_ended`, `no_trial`, `no_recipient`, `no_trial_recipient`). A failed send is retried by the next run inside the window. Sent emails record `card_trial_offer_emailed` (`metadata.kind` = `trial`, or `plan` when under 48 hours were left and the email linked to the plans instead). The email's button opens `/start-trial?source=first_success_email` (or `/settings/license?source=first_success_email`, which passes the source on to its card-trial button and its pricing link), so the email and the in-app card are counted apart. Emails sent before 16 October 2026 say `source=first_success`, which is still accepted.
The email talks about value only (the connection works, up to three things to ask next, taken from the connector's own tools, the trial's end date and a link back to the app), never about cards, charges or prices, and it carries the unsubscribe line. Nothing of the offer is ever part of an MCP tool response.
## Attribution to Stripe
-1. `/start-trial?source=first_success` sends `source: "first_success"` with `POST /api/license/checkout-link`. The backend accepts only known sources and records `checkout_link_created` (`metadata.via` = the source, or `app` for every other entry point; `metadata.kind` = `trial` or `pay`).
-2. The backend passes `source` to the licence site's `POST /api/stripe/checkout-intent`, which stores it on the intent.
-3. The site writes it to the Stripe Checkout Session and to the subscription as `metadata.trial_source = "first_success"`.
+Every Cloud checkout names where it started, and the licence site writes it to the Stripe Checkout Session and to the subscription as `metadata.checkout_source`. Card trials also get the same value as `metadata.trial_source` (the key the first version used; it is left off pay-now checkouts, where the name would be wrong).
-A licence site without step 2 ignores the field, so the backend can be deployed first.
+**In-app checkouts** (`POST /api/license/checkout-link` → the site's `POST /api/stripe/checkout-intent`). The frontend sends `source`; the backend accepts only `CHECKOUT_SOURCES` (`packages/backend/src/license/license-checkout.ts`, mirrored in `packages/frontend/src/lib/checkout-source.ts`) and sends `app` when the page named none, so no intent goes without one. It also records `checkout_link_created` with `metadata.via` = the same value and `metadata.kind` = `trial` or `pay`.
+
+| Source | Entry point |
+|---|---|
+| `first_success_banner` | the first-success card (dashboard, connectors, connect page) |
+| `first_success_email` | the first-success email |
+| `first_success` | the same offer before banner and email were told apart (old emails, open tabs) |
+| `trial_banner` | the countdown banner's "Add a payment method" / "Choose a plan" |
+| `license_page` | Settings → License |
+| `trial_ended_wall` | the wall after the free trial ("Subscribe to …") |
+| `post_approve` | "add a card now" on the page after approving an AI client |
+| `signup_prompt` | the card-trial page shown once after sign-up |
+| `trial_limit`, `starter_pack` | "add a card" next to the connector limit |
+| `chat_setup` | the plan link the setup tools give the AI (`upgradeUrl`, `choosePlanUrl`) |
+| `start_trial` | `/start-trial` opened without a known source |
+| `app` | a checkout the page did not name |
+
+**Pricing-page checkouts** (`anythingmcp.com/pricing` → `POST /api/stripe/checkout`). Links to the pricing page carry `?source=`: `app_pricing` (any app link, the default of `usePricingUrl`), `trial_banner`, `license_page`, `trial_ended_wall`, `first_success_email`, `chat_setup`, and from the Cloud's emails `email_trial_warn3`, `email_trial_warn1`, `email_trial_expired`, `email_winback`. The site keeps only the values in its `PRICING_CHECKOUT_SOURCES` and records everything else, a visitor who came on their own included, as `pricing_page`.
+
+**`/start-trial` events.** `start_trial_viewed` (once per page load) and `start_trial_checkout_started` (the button), both with `metadata.source` and `metadata.kind` (`trial`, or `pay` for an address that had a subscription). Read them against `checkout_link_created` to see where each entry point's visitors stop.
+
+**Deploy order.** Either order is safe. The site already stores any slug it gets on an intent (as `trial_source` before the site change, `checkout_source` after it) and ignores `?source=` on the pricing page until it knows it. Deploying the site first means every Cloud checkout lands in `checkout_source` from the first minute.
## Weekly report
@@ -50,7 +69,7 @@ steps AS (
bool_or(event = 'card_trial_offer_clicked') AS clicked,
bool_or(event = 'card_trial_offer_dismissed') AS dismissed,
bool_or(event = 'card_trial_offer_emailed') AS emailed,
- bool_or(event = 'checkout_link_created' AND metadata->>'via' = 'first_success') AS checkout_opened
+ bool_or(event = 'checkout_link_created' AND metadata->>'via' LIKE 'first_success%') AS checkout_opened
FROM product_events
WHERE event IN ('card_trial_offer_shown', 'card_trial_offer_clicked', 'card_trial_offer_dismissed',
'card_trial_offer_emailed', 'checkout_link_created')
@@ -79,11 +98,50 @@ GROUP BY 1
ORDER BY 1 DESC;
```
-**Stripe:** card trials started from the offer, and how many of them paid after the trial, by the week the subscription was created. Run with the licence site's Stripe secret key in `STRIPE_KEY` (the Search API returns at most 100 rows per page; follow `next_page` when `has_more` is true).
+**Cloud database, every entry point:** how far each source's visitors got over the last week.
+
+```sql
+SELECT coalesce(metadata->>'source', metadata->>'via') AS source,
+ count(*) FILTER (WHERE event = 'start_trial_viewed') AS start_trial_views,
+ count(*) FILTER (WHERE event = 'start_trial_checkout_started') AS start_trial_clicks,
+ count(*) FILTER (WHERE event = 'checkout_link_created') AS checkout_links,
+ count(*) FILTER (WHERE event = 'checkout_link_created'
+ AND metadata->>'kind' = 'trial') AS card_trial_links
+FROM product_events
+WHERE event IN ('start_trial_viewed', 'start_trial_checkout_started', 'checkout_link_created')
+ AND created_at >= now() - interval '7 days'
+GROUP BY 1
+ORDER BY checkout_links DESC;
+```
+
+**Stripe, every entry point:** Checkout Sessions of the last week by `checkout_source`, and how many completed. Sessions have no Search API, so the script lists them (100 per page). Run with the licence site's Stripe secret key in `STRIPE_KEY`; `SINCE` is a Unix time (`date -v-7d +%s` on macOS, `date -d '7 days ago' +%s` on Linux).
+
+```bash
+after=""
+while :; do
+ page=$(curl -sG https://api.stripe.com/v1/checkout/sessions -u "$STRIPE_KEY:" \
+ -d limit=100 -d "created[gte]=$SINCE" ${after:+-d starting_after=$after})
+ echo "$page" | jq -c '.data[] | select(.metadata.app == "anythingmcp")'
+ [ "$(echo "$page" | jq -r .has_more)" = true ] || break
+ after=$(echo "$page" | jq -r '.data[-1].id')
+done |
+jq -rs 'group_by(.metadata.checkout_source // .metadata.trial_source // "(none)")[]
+ | [ (.[0].metadata.checkout_source // .metadata.trial_source // "(none)"),
+ length,
+ (map(select(.status == "complete")) | length),
+ (map(select(.metadata.trial == "1")) | length),
+ (map(select(.metadata.trial == "1" and .status == "complete")) | length) ]
+ | @tsv' |
+(echo -e "source\tsessions\tcompleted\ttrial_sessions\ttrials_completed"; cat)
+```
+
+`(none)` is a session from before the change. Subscriptions are searchable directly: `metadata['checkout_source']:'first_success_email'`.
+
+**Stripe, the offer:** card trials started from the offer, and how many of them paid after the trial, by the week the subscription was created. Run with the licence site's Stripe secret key in `STRIPE_KEY` (the Search API returns at most 100 rows per page; follow `next_page` when `has_more` is true).
```bash
curl -sG https://api.stripe.com/v1/subscriptions/search -u "$STRIPE_KEY:" \
- --data-urlencode "query=metadata['trial_source']:'first_success'" \
+ --data-urlencode "query=metadata['checkout_source']:'first_success_banner' OR metadata['checkout_source']:'first_success_email' OR metadata['trial_source']:'first_success'" \
-d limit=100 -d 'expand[]=data.latest_invoice' |
jq -r --argjson now "$(date +%s)" '
[.data[] | {
diff --git a/packages/backend/src/adapters/connector-setup.service.spec.ts b/packages/backend/src/adapters/connector-setup.service.spec.ts
index f5adf3c7..f1da2b44 100644
--- a/packages/backend/src/adapters/connector-setup.service.spec.ts
+++ b/packages/backend/src/adapters/connector-setup.service.spec.ts
@@ -256,7 +256,7 @@ describe('ConnectorSetupService — install', () => {
const out: any = await service.install(ctx, { adapter: 'openplz' });
expect(out.body).toMatchObject({
error: 'Trial limit reached (2 connectors).',
- upgradeUrl: 'https://cloud.example.com/start-trial',
+ upgradeUrl: 'https://cloud.example.com/start-trial?source=chat_setup',
});
expect(out.body.whatTheUserCanDo).toMatch(/nothing is charged before the trial ends/);
});
@@ -266,7 +266,7 @@ describe('ConnectorSetupService — install', () => {
licenseGuard.checkCanCreateConnector.mockRejectedValueOnce(new Error('Connector limit reached.'));
licenseGuard.getUsage.mockResolvedValueOnce({ plan: 'cloud_starter', connectors: { current: 5, max: 5 } });
const out: any = await service.install(ctx, { adapter: 'openplz' });
- expect(out.body.upgradeUrl).toBe('https://cloud.example.com/settings/license');
+ expect(out.body.upgradeUrl).toBe('https://cloud.example.com/settings/license?source=chat_setup');
});
it('at the limit, tells an editor who can lift it, without a billing link', async () => {
@@ -292,7 +292,7 @@ describe('ConnectorSetupService — status', () => {
expect(out.trial).toEqual({
daysLeft: 5,
endsAt: expect.any(String),
- choosePlanUrl: 'https://cloud.example.com/start-trial',
+ choosePlanUrl: 'https://cloud.example.com/start-trial?source=chat_setup',
afterTheTrial: expect.stringMatching(/Nothing is charged before then/),
});
});
@@ -300,7 +300,7 @@ describe('ConnectorSetupService — status', () => {
it('sends an admin to the licence page once a card trial no longer fits', async () => {
const { service, ctx } = build({ role: 'ADMIN', trial: running(1, false) });
const out: any = (await service.status(ctx)).body;
- expect(out.trial).toMatchObject({ daysLeft: 1, choosePlanUrl: 'https://cloud.example.com/settings/license' });
+ expect(out.trial).toMatchObject({ daysLeft: 1, choosePlanUrl: 'https://cloud.example.com/settings/license?source=chat_setup' });
expect(out.trial.afterTheTrial).not.toMatch(/card/i);
});
@@ -313,7 +313,7 @@ describe('ConnectorSetupService — status', () => {
it('reports an ended trial, with the licence page for an admin', async () => {
const ended = { endsAt: new Date(Date.now() - DAY), active: false, cardTrialAvailable: false };
const admin: any = (await build({ role: 'ADMIN', trial: ended }).service.status(build().ctx)).body;
- expect(admin.trial).toMatchObject({ ended: true, choosePlanUrl: 'https://cloud.example.com/settings/license' });
+ expect(admin.trial).toMatchObject({ ended: true, choosePlanUrl: 'https://cloud.example.com/settings/license?source=chat_setup' });
const editor: any = (await build({ role: 'EDITOR', trial: ended }).service.status(build().ctx)).body;
expect(editor.trial).toEqual({ ended: true, endedAt: expect.any(String), afterTheTrial: expect.stringMatching(/administrator/) });
});
diff --git a/packages/backend/src/adapters/connector-setup.service.ts b/packages/backend/src/adapters/connector-setup.service.ts
index abf6124a..f7f192fa 100644
--- a/packages/backend/src/adapters/connector-setup.service.ts
+++ b/packages/backend/src/adapters/connector-setup.service.ts
@@ -17,6 +17,7 @@ import { computeSetupState } from '../connectors/connector-setup-status.util';
import { decrypt } from '../common/crypto/encryption.util';
import { findSecretLiterals } from '../common/secret-literals.util';
import { searchCatalog } from './catalog-search';
+import { CHAT_SETUP_SOURCE } from '../license/license-checkout';
/** Assistants a setup link can name (see assistantForRedirectUris). */
const RETURN_ASSISTANTS: readonly ReturnAssistant[] = ['claude', 'chatgpt', 'muse'];
@@ -350,7 +351,7 @@ export class ConnectorSetupService implements SharedSetupProvider, OnModuleInit
whatTheUserCanDo: onTrial
? 'Add a card to continue the trial on the full plan (nothing is charged before the trial ends), or remove a connector.'
: 'Choose a plan with more connectors, or remove a connector.',
- upgradeUrl: `${ctx.dashboardBase}${onTrial ? '/start-trial' : '/settings/license'}`,
+ upgradeUrl: `${ctx.dashboardBase}${onTrial ? '/start-trial' : '/settings/license'}?source=${CHAT_SETUP_SOURCE}`,
};
}
@@ -417,7 +418,7 @@ export class ConnectorSetupService implements SharedSetupProvider, OnModuleInit
ended: true,
endedAt: endsAt,
...(admin
- ? { choosePlanUrl: `${ctx.dashboardBase}/settings/license`, afterTheTrial: 'The trial has ended. Choosing a plan turns the connectors back on.' }
+ ? { choosePlanUrl: `${ctx.dashboardBase}/settings/license?source=${CHAT_SETUP_SOURCE}`, afterTheTrial: 'The trial has ended. Choosing a plan turns the connectors back on.' }
: { afterTheTrial: 'The trial has ended. A workspace administrator can choose a plan.' }),
};
}
@@ -429,13 +430,13 @@ export class ConnectorSetupService implements SharedSetupProvider, OnModuleInit
? {
daysLeft,
endsAt,
- choosePlanUrl: `${ctx.dashboardBase}/start-trial`,
+ choosePlanUrl: `${ctx.dashboardBase}/start-trial?source=${CHAT_SETUP_SOURCE}`,
afterTheTrial: 'Adding a card keeps the workspace running when the trial ends. Nothing is charged before then.',
}
: {
daysLeft,
endsAt,
- choosePlanUrl: `${ctx.dashboardBase}/settings/license`,
+ choosePlanUrl: `${ctx.dashboardBase}/settings/license?source=${CHAT_SETUP_SOURCE}`,
afterTheTrial: 'Choosing a plan keeps the workspace running when the trial ends.',
};
}
diff --git a/packages/backend/src/audit/product-event.service.ts b/packages/backend/src/audit/product-event.service.ts
index 421fed31..7c7fb72f 100644
--- a/packages/backend/src/audit/product-event.service.ts
+++ b/packages/backend/src/audit/product-event.service.ts
@@ -126,12 +126,27 @@ export const ProductEvents = {
* of trial left, linked to the plans). Server-only.
*/
CARD_TRIAL_OFFER_EMAILED: 'card_trial_offer_emailed',
+ /**
+ * The card-trial page (/start-trial) was opened, once per page load.
+ * metadata.source = the entry point that led there (CHECKOUT_SOURCES in
+ * license-checkout.ts, e.g. 'first_success_banner', 'first_success_email',
+ * 'trial_banner'), metadata.kind = 'trial' (card trial on offer) or 'pay'
+ * (the address had a subscription, the plan is billed today). Answers how
+ * many clicks on each entry point, emails included, reach the page.
+ */
+ START_TRIAL_VIEWED: 'start_trial_viewed',
+ /**
+ * Its checkout button was pressed: metadata.source and metadata.kind as
+ * above. Read against start_trial_viewed (who leaves without trying) and
+ * checkout_link_created (who reaches Stripe).
+ */
+ START_TRIAL_CHECKOUT_STARTED: 'start_trial_checkout_started',
/**
* The app minted a Stripe Checkout link (POST /api/license/checkout-link).
- * metadata.via = where it came from ('first_success', or 'app' for every
- * other entry point), metadata.kind = 'trial' (card trial) or 'pay'.
- * Server-only. Answers how many clicks reach Checkout; Stripe says how
- * many finish (metadata.trial_source).
+ * metadata.via = the entry point it came from (CHECKOUT_SOURCES, 'app' when
+ * the page did not say), metadata.kind = 'trial' (card trial) or 'pay'.
+ * Server-only. Answers how many clicks reach Checkout; Stripe says how many
+ * finish (metadata.checkout_source, the same value).
*/
CHECKOUT_LINK_CREATED: 'checkout_link_created',
} as const;
diff --git a/packages/backend/src/auth/login.controller.spec.ts b/packages/backend/src/auth/login.controller.spec.ts
index 23c672cf..85fa8579 100644
--- a/packages/backend/src/auth/login.controller.spec.ts
+++ b/packages/backend/src/auth/login.controller.spec.ts
@@ -638,7 +638,7 @@ describe('LoginController', () => {
expect(res._sent).toContain('Your free trial runs until October 13.');
expect(res._sent).toContain('Nothing is charged before October 13.');
expect(res._sent).toMatch(
- /add a card now \(new tab\)<\/a>/,
+ /add a card now \(new tab\)<\/a>/,
);
// Continue stays the first and only button.
const html = res._sent ?? '';
diff --git a/packages/backend/src/auth/login.controller.ts b/packages/backend/src/auth/login.controller.ts
index 8251da26..98196949 100644
--- a/packages/backend/src/auth/login.controller.ts
+++ b/packages/backend/src/auth/login.controller.ts
@@ -29,6 +29,7 @@ import { McpConnectionGrantService } from '../mcp-servers/mcp-connection-grant.s
import { ProductEvents, ProductEventService } from '../audit/product-event.service';
import { LicenseGuardService } from '../license/license-guard.service';
import { CardTrialEligibilityService } from '../license/card-trial-eligibility.service';
+import { POST_APPROVE_SOURCE } from '../license/license-checkout';
import {
AMCP_MARK_SVG as AMCP_MARK,
claudeMark,
@@ -446,7 +447,7 @@ export class LoginController {
redirectHost: consent.redirectHost,
callbackUrl,
welcomeUrl: `${dashboard}/welcome`,
- ...(trialEndsAt ? { cardTrial: { endsAt: trialEndsAt, url: `${dashboard}/start-trial` } } : {}),
+ ...(trialEndsAt ? { cardTrial: { endsAt: trialEndsAt, url: `${dashboard}/start-trial?source=${POST_APPROVE_SOURCE}` } } : {}),
}),
);
return true;
diff --git a/packages/backend/src/license/card-trial-offer.service.ts b/packages/backend/src/license/card-trial-offer.service.ts
index 7c1e780b..2fa0afb3 100644
--- a/packages/backend/src/license/card-trial-offer.service.ts
+++ b/packages/backend/src/license/card-trial-offer.service.ts
@@ -5,7 +5,7 @@ import { EmailService } from '../settings/email.service';
import { ProductEvents, ProductEventService } from '../audit/product-event.service';
import { CardTrialEligibilityService } from './card-trial-eligibility.service';
import {
- FIRST_SUCCESS_SOURCE,
+ FIRST_SUCCESS_EMAIL_SOURCE,
OFFER_ELIGIBLE_KEY,
OFFER_EMAIL_AFTER_MS,
OFFER_EMAIL_KEY,
@@ -212,8 +212,8 @@ export class CardTrialOfferService {
const { connector, toolName } = await this.firstSuccessConnector(organizationId, true);
const nextSteps = pickNextSteps(connector?.tools ?? [], toolName);
const ctaPath = cardTrial
- ? `/start-trial?source=${FIRST_SUCCESS_SOURCE}`
- : `/settings/license?source=${FIRST_SUCCESS_SOURCE}`;
+ ? `/start-trial?source=${FIRST_SUCCESS_EMAIL_SOURCE}`
+ : `/settings/license?source=${FIRST_SUCCESS_EMAIL_SOURCE}`;
let sentAny = false;
for (const r of trialRecipients) {
diff --git a/packages/backend/src/license/card-trial-offer.spec.ts b/packages/backend/src/license/card-trial-offer.spec.ts
index 1bf4692c..46f473c4 100644
--- a/packages/backend/src/license/card-trial-offer.spec.ts
+++ b/packages/backend/src/license/card-trial-offer.spec.ts
@@ -251,7 +251,7 @@ describe('CardTrialOfferService.runEmailPass', () => {
],
trialEndsAt: new Date(NOW.getTime() + 5 * DAY),
cardTrial: true,
- ctaPath: '/start-trial?source=first_success',
+ ctaPath: '/start-trial?source=first_success_email',
});
expect(emailFlag()).toBe(NOW.toISOString());
expect(emailedEvents()).toEqual([
@@ -296,7 +296,7 @@ describe('CardTrialOfferService.runEmailPass', () => {
await svc.runEmailPass(NOW);
expect(email.sendCardTrialOfferEmail).toHaveBeenCalledWith(
'ada@example.com',
- expect.objectContaining({ cardTrial: false, ctaPath: '/settings/license?source=first_success' }),
+ expect.objectContaining({ cardTrial: false, ctaPath: '/settings/license?source=first_success_email' }),
);
expect(emailedEvents()[0].metadata.kind).toBe('plan');
});
diff --git a/packages/backend/src/license/card-trial-offer.ts b/packages/backend/src/license/card-trial-offer.ts
index 8f0eea54..dd2ddd39 100644
--- a/packages/backend/src/license/card-trial-offer.ts
+++ b/packages/backend/src/license/card-trial-offer.ts
@@ -28,8 +28,12 @@ export const OFFER_ELIGIBLE_KEY = 'card_trial_offer_eligible_at';
*/
export const OFFER_EMAIL_KEY = 'card_trial_offer_email';
-/** `?source=` on /start-trial, carried into the checkout and Stripe metadata. */
-export const FIRST_SUCCESS_SOURCE = 'first_success';
+/**
+ * `?source=` on the offer email's link (to /start-trial, or to the licence
+ * page when the card trial is no longer available), carried into the
+ * checkout and Stripe metadata. The in-app card uses 'first_success_banner'.
+ */
+export const FIRST_SUCCESS_EMAIL_SOURCE = 'first_success_email';
/**
* A first success older than this when we first see it does not make the
diff --git a/packages/backend/src/license/license-checkout.spec.ts b/packages/backend/src/license/license-checkout.spec.ts
index 21b4eeed..ad9c87b2 100644
--- a/packages/backend/src/license/license-checkout.spec.ts
+++ b/packages/backend/src/license/license-checkout.spec.ts
@@ -25,10 +25,12 @@ import { CheckoutLinkDto, LicenseController } from './license.controller';
import { LicenseService } from './license.service';
import {
CHECKOUT_PLANS,
+ CHECKOUT_SOURCES,
CheckoutUnavailableError,
cardTrialEnd,
checkoutAdMetadata,
cloudFrontendOrigin,
+ withSourceParam,
} from './license-checkout';
jest.mock('axios');
@@ -296,6 +298,7 @@ describe('LicenseController.checkoutLink', () => {
returnUrl: 'https://cloud.example.com/settings/license/activate',
organizationId: 'org-1',
adMetadata: { ad_consent: 'granted', gclid: 'g-1' },
+ source: 'app',
});
});
@@ -439,21 +442,23 @@ describe('LicenseController.checkoutLink', () => {
plan: 'starter',
billingPeriod: 'monthly',
trial: true,
- source: 'first_success',
+ source: 'first_success_banner',
});
const [payload] = licenseService.createCheckoutIntent.mock.calls[0] as any[];
- expect(payload.source).toBe('first_success');
+ expect(payload.source).toBe('first_success_banner');
expect(productEvents.log).toHaveBeenCalledWith({
event: 'checkout_link_created',
userId: 'u1',
organizationId: 'org-1',
- metadata: { via: 'first_success', kind: 'trial' },
+ metadata: { via: 'first_success_banner', kind: 'trial' },
});
+ });
+ it('sends a source with every checkout: "app" when the page does not name one', async () => {
const plain = makeController();
await plain.controller.checkoutLink(adminReq, { plan: 'team', billingPeriod: 'monthly', trial: false });
const [plainPayload] = plain.licenseService.createCheckoutIntent.mock.calls[0] as any[];
- expect(plainPayload).not.toHaveProperty('source');
+ expect(plainPayload.source).toBe('app');
expect(plain.productEvents.log).toHaveBeenCalledWith(
expect.objectContaining({ metadata: { via: 'app', kind: 'pay' } }),
);
@@ -469,8 +474,10 @@ describe('LicenseController.checkoutLink', () => {
it('accepts only known sources: the value ends up in Stripe metadata', async () => {
const base = { plan: 'starter', billingPeriod: 'monthly', trial: true };
- const ok = await validate(plainToInstance(CheckoutLinkDto, { ...base, source: 'first_success' }));
- expect(ok).toHaveLength(0);
+ for (const source of CHECKOUT_SOURCES) {
+ const ok = await validate(plainToInstance(CheckoutLinkDto, { ...base, source }));
+ expect(ok).toHaveLength(0);
+ }
const none = await validate(plainToInstance(CheckoutLinkDto, base));
expect(none).toHaveLength(0);
for (const source of ['newsletter', 'first_success; drop', '', 42]) {
@@ -497,3 +504,14 @@ describe('LicenseController.checkoutLink', () => {
expect(err.message).not.toMatch(/HTTP 500/);
});
});
+
+describe('withSourceParam', () => {
+ it('adds the source to the query, ahead of any fragment', () => {
+ expect(withSourceParam('https://anythingmcp.com/pricing', 'email_winback')).toBe(
+ 'https://anythingmcp.com/pricing?source=email_winback',
+ );
+ expect(withSourceParam('https://anythingmcp.com/pricing?promo=X#t=tok', 'email_trial_warn1')).toBe(
+ 'https://anythingmcp.com/pricing?promo=X&source=email_trial_warn1#t=tok',
+ );
+ });
+});
diff --git a/packages/backend/src/license/license-checkout.ts b/packages/backend/src/license/license-checkout.ts
index 5ec876e3..7d91f375 100644
--- a/packages/backend/src/license/license-checkout.ts
+++ b/packages/backend/src/license/license-checkout.ts
@@ -25,13 +25,68 @@ export type CheckoutPlan = (typeof CHECKOUT_PLANS)[number];
export const CHECKOUT_BILLING_PERIODS = ['monthly', 'yearly'] as const;
/**
- * Where in the app a checkout was started, when it is worth measuring. Sent
- * to the licence site as `source`, which stores it on the Stripe Checkout
- * Session and the subscription as `metadata.trial_source`. Allow-listed: the
- * value ends up in Stripe.
+ * Where in the app a checkout was started. Sent to the licence site as
+ * `source` with every checkout intent; the site stores it on the Stripe
+ * Checkout Session and the subscription as `metadata.checkout_source` (and as
+ * `metadata.trial_source` on card trials). Allow-listed: the value ends up in
+ * Stripe. The frontend mirrors this list in src/lib/checkout-source.ts.
+ *
+ * - first_success_banner / first_success_email: the card-trial offer after a
+ * workspace's first successful tool call, from the in-app card or the email.
+ * - first_success: the same offer before banner and email were told apart
+ * (links in emails already sent, tabs already open). Still accepted.
+ * - trial_banner: the countdown banner at the top of every page.
+ * - license_page: Settings → License.
+ * - trial_ended_wall: the wall shown when the free trial is over.
+ * - post_approve: "add a card" on the page after approving an AI client.
+ * - signup_prompt: the card-trial page shown once after sign-up.
+ * - trial_limit / starter_pack: "add a card" next to the connector limit.
+ * - chat_setup: the plan link the setup tools give the AI in a chat.
+ * - start_trial: /start-trial opened without a known source.
+ * - app: a caller that did not say (DEFAULT_CHECKOUT_SOURCE).
*/
-export const CHECKOUT_SOURCES = ['first_success'] as const;
+export const CHECKOUT_SOURCES = [
+ 'first_success_banner',
+ 'first_success_email',
+ 'first_success',
+ 'trial_banner',
+ 'license_page',
+ 'trial_ended_wall',
+ 'post_approve',
+ 'signup_prompt',
+ 'trial_limit',
+ 'starter_pack',
+ 'chat_setup',
+ 'start_trial',
+ 'app',
+] as const;
export type CheckoutSource = (typeof CHECKOUT_SOURCES)[number];
+/** The setup tools' plan links (`?source=` on /start-trial and /settings/license). */
+export const CHAT_SETUP_SOURCE: CheckoutSource = 'chat_setup';
+/** "Add a card" on the page after approving an AI client (login.controller). */
+export const POST_APPROVE_SOURCE: CheckoutSource = 'post_approve';
+/** Sent when the app did not name an entry point, so no checkout goes without one. */
+export const DEFAULT_CHECKOUT_SOURCE: CheckoutSource = 'app';
+
+/**
+ * Where a link to the public pricing page came from, as `?source=` on it. The
+ * pricing page passes it to its checkout, which stores it as
+ * `metadata.checkout_source` (anything it does not know becomes
+ * `pricing_page`). Kept in step with PRICING_CHECKOUT_SOURCES on the site.
+ */
+export type PricingLinkSource =
+ | 'email_trial_warn3'
+ | 'email_trial_warn1'
+ | 'email_trial_expired'
+ | 'email_winback';
+
+/** `url` with `source=` added to its query (before any fragment). */
+export function withSourceParam(url: string, source: CheckoutSource | PricingLinkSource): string {
+ const hashAt = url.indexOf('#');
+ const base = hashAt === -1 ? url : url.slice(0, hashAt);
+ const hash = hashAt === -1 ? '' : url.slice(hashAt);
+ return `${base}${base.includes('?') ? '&' : '?'}source=${encodeURIComponent(source)}${hash}`;
+}
export type CheckoutBillingPeriod = (typeof CHECKOUT_BILLING_PERIODS)[number];
/** Body of POST {licence site}/api/stripe/checkout-intent (the agreed contract). */
@@ -45,7 +100,7 @@ export interface CheckoutIntentPayload {
organizationId?: string;
/** A Stripe promotion code the user arrived with; the site pre-applies it. */
promoCode?: string;
- /** The app entry point (CHECKOUT_SOURCES); Stripe metadata `trial_source`. */
+ /** The app entry point (CHECKOUT_SOURCES); Stripe metadata `checkout_source`. */
source?: CheckoutSource;
adMetadata?: {
ad_consent: 'granted';
diff --git a/packages/backend/src/license/license.controller.ts b/packages/backend/src/license/license.controller.ts
index 71e650ea..32b9778a 100644
--- a/packages/backend/src/license/license.controller.ts
+++ b/packages/backend/src/license/license.controller.ts
@@ -35,6 +35,7 @@ import {
CheckoutBillingPeriod,
CheckoutPlan,
CheckoutSource,
+ DEFAULT_CHECKOUT_SOURCE,
cardTrialEnd,
checkoutAdMetadata,
cloudFrontendOrigin,
@@ -67,8 +68,9 @@ export class CheckoutLinkDto {
@Matches(/^[A-Za-z0-9_-]{1,64}$/)
promo?: string;
- // Which in-app offer led here (e.g. 'first_success', the card-trial offer
- // after the first successful tool call). Lands in Stripe metadata.
+ // Where in the app the checkout was started (e.g. 'first_success_banner',
+ // the card-trial offer after the first successful tool call). Lands in
+ // Stripe metadata as checkout_source; 'app' when the page does not say.
@IsOptional()
@IsIn(CHECKOUT_SOURCES as unknown as string[])
source?: CheckoutSource;
@@ -338,6 +340,8 @@ export class LicenseController {
const adMetadata = checkoutAdMetadata(
await this.productEvents.clickIdForUser(req.user.sub).catch(() => null),
);
+ // Every checkout carries its entry point, so Stripe can count each one.
+ const source: CheckoutSource = dto.source ?? DEFAULT_CHECKOUT_SOURCE;
try {
const link = await this.licenseService.createCheckoutIntent({
@@ -349,13 +353,13 @@ export class LicenseController {
organizationId,
...(dto.promo && { promoCode: dto.promo.toUpperCase() }),
...(adMetadata && { adMetadata }),
- ...(dto.source && { source: dto.source }),
+ source,
});
void this.productEvents.log({
event: ProductEvents.CHECKOUT_LINK_CREATED,
userId: req.user.sub,
organizationId,
- metadata: { via: dto.source ?? 'app', kind: trialEnd ? 'trial' : 'pay' },
+ metadata: { via: source, kind: trialEnd ? 'trial' : 'pay' },
});
return link;
} catch (err: any) {
diff --git a/packages/backend/src/settings/email-samples.ts b/packages/backend/src/settings/email-samples.ts
index 99c91ca0..3c63ec96 100644
--- a/packages/backend/src/settings/email-samples.ts
+++ b/packages/backend/src/settings/email-samples.ts
@@ -218,7 +218,7 @@ export const EMAIL_SAMPLES: EmailSample[] = [
{ name: n.other, text: n.name },
],
trialEndsAt: SAMPLE_TRIAL_ENDS_AT,
- ctaUrl: `${SAMPLE_CLOUD_URL}/start-trial?source=first_success`,
+ ctaUrl: `${SAMPLE_CLOUD_URL}/start-trial?source=first_success_email`,
cardTrial: true,
},
unsub(ctx),
@@ -234,7 +234,7 @@ export const EMAIL_SAMPLES: EmailSample[] = [
connectorName: null,
nextSteps: [],
trialEndsAt: SAMPLE_TRIAL_ENDS_AT,
- ctaUrl: `${SAMPLE_CLOUD_URL}/settings/license?source=first_success`,
+ ctaUrl: `${SAMPLE_CLOUD_URL}/settings/license?source=first_success_email`,
cardTrial: false,
},
unsub(ctx),
diff --git a/packages/backend/src/settings/email-templates.spec.ts b/packages/backend/src/settings/email-templates.spec.ts
index 4df4b517..bbf25abc 100644
--- a/packages/backend/src/settings/email-templates.spec.ts
+++ b/packages/backend/src/settings/email-templates.spec.ts
@@ -183,7 +183,8 @@ describe('trial win-back', () => {
}
expect(email.text).toContain(
'https://anythingmcp.com/pricing?promo=STARTER599&return_url=' +
- encodeURIComponent('https://cloud.anythingmcp.com/settings/license/activate'),
+ encodeURIComponent('https://cloud.anythingmcp.com/settings/license/activate') +
+ '&source=email_winback',
);
expect(email.marketing).toBe(true);
});
@@ -215,15 +216,15 @@ describe('card-trial offer after the first success', () => {
it('dates the end of the trial and links to the card trial with its source', () => {
const email = render('card-trial-offer');
for (const part of [email.html, email.text]) expect(part).toContain('18 October');
- expect(email.text).toContain('Start your trial: https://cloud.anythingmcp.com/start-trial?source=first_success');
- expect(email.html).toContain('href="https://cloud.anythingmcp.com/start-trial?source=first_success"');
+ expect(email.text).toContain('Start your trial: https://cloud.anythingmcp.com/start-trial?source=first_success_email');
+ expect(email.html).toContain('href="https://cloud.anythingmcp.com/start-trial?source=first_success_email"');
expect(email.marketing).toBe(true);
});
it('links to the plans when the card trial can no longer start, and copes without a connector', () => {
const email = render('card-trial-offer-plans');
expect(email.subject).toBe('Your first connection works');
- expect(email.text).toContain('Choose a plan: https://cloud.anythingmcp.com/settings/license?source=first_success');
+ expect(email.text).toContain('Choose a plan: https://cloud.anythingmcp.com/settings/license?source=first_success_email');
expect(email.text).toContain('Ask your AI what it can do with your app');
});
diff --git a/packages/backend/src/settings/email-templates.ts b/packages/backend/src/settings/email-templates.ts
index d29919e6..f79ee518 100644
--- a/packages/backend/src/settings/email-templates.ts
+++ b/packages/backend/src/settings/email-templates.ts
@@ -15,6 +15,7 @@ import {
usageBox,
} from './email-layout';
import { withEmailLinkToken } from '../common/email-link-token';
+import { withSourceParam } from '../license/license-checkout';
/**
* Every email the backend sends, as pure functions of their inputs: no
@@ -301,8 +302,13 @@ export function trialLifecycleEmail(
ctx: EmailBrandContext,
): RenderedEmail {
const { stage, recap, cloudUrl } = input;
+ // `source` reaches the checkout's Stripe metadata (checkout_source), so a
+ // purchase from this email can be told apart from the pricing page's own.
const pricingUrl = withEmailLinkToken(
- `${input.marketingUrl}/pricing?return_url=${encodeURIComponent(`${cloudUrl}/settings/license/activate`)}`,
+ withSourceParam(
+ `${input.marketingUrl}/pricing?return_url=${encodeURIComponent(`${cloudUrl}/settings/license/activate`)}`,
+ `email_trial_${stage}`,
+ ),
input.emailToken,
);
const licenseUrl = `${cloudUrl}/settings/license`;
@@ -455,7 +461,9 @@ export function trialWinbackEmail(
): RenderedEmail {
const { offer, cloudUrl, marketingUrl } = input;
const returnUrl = encodeURIComponent(`${cloudUrl}/settings/license/activate`);
- const priced = (url: string) => withEmailLinkToken(url, input.emailToken);
+ // Every pricing link carries `source=email_winback` into the checkout's
+ // Stripe metadata; the promo code already tells the offers apart.
+ const priced = (url: string) => withEmailLinkToken(withSourceParam(url, 'email_winback'), input.emailToken);
const hi = greeting(input.name);
const endedOn = dayAndMonth(offer.trialEndedAt);
const replyLine = 'Not the right time, or something was missing? Reply and tell us; we read every answer.';
diff --git a/packages/backend/src/settings/email.service.spec.ts b/packages/backend/src/settings/email.service.spec.ts
index dcaa7a08..4685a141 100644
--- a/packages/backend/src/settings/email.service.spec.ts
+++ b/packages/backend/src/settings/email.service.spec.ts
@@ -196,6 +196,8 @@ describe('EmailService', () => {
expect(readEmailLinkToken(token)).toBe('ann@example.com');
// The address itself never appears in the link.
expect(link.toLowerCase()).not.toContain('ann@example.com');
+ // The email names itself to the checkout, ahead of the token.
+ expect(new URL(link).searchParams.get('source')).toMatch(/^email_(trial_(warn3|warn1|expired)|winback)$/);
}
}
});
@@ -205,7 +207,9 @@ describe('EmailService', () => {
await service.sendTrialLifecycleEmail('a@b.com', 'Ada', 'expired', { connectors: 0, successfulCalls: 0, daysLeft: 0 });
const links = pricingLinks(sent().text);
expect(links).toEqual([
- 'https://anythingmcp.com/pricing?return_url=' + encodeURIComponent('https://cloud.anythingmcp.com/settings/license/activate'),
+ 'https://anythingmcp.com/pricing?return_url=' +
+ encodeURIComponent('https://cloud.anythingmcp.com/settings/license/activate') +
+ '&source=email_trial_expired',
]);
});
});
diff --git a/packages/frontend/src/app/login/page.tsx b/packages/frontend/src/app/login/page.tsx
index cba53111..7790e7d7 100644
--- a/packages/frontend/src/app/login/page.tsx
+++ b/packages/frontend/src/app/login/page.tsx
@@ -15,6 +15,7 @@ import { cn } from '@/lib/utils';
import { useTrustStats } from '@/lib/trust-stats';
import { TrustLine, TrustPanel } from '@/components/auth-trust';
import { safeRedirect } from '@/lib/safe-redirect';
+import { startTrialPath } from '@/lib/checkout-source';
import { captureSignupAttribution, clearSignupAttribution, getSignupAttribution } from '@/lib/attribution';
import { pushSignUpVerified } from '@/lib/conversion';
import {
@@ -188,7 +189,7 @@ function LoginForm() {
});
if (!eligible || readCardTrialPrompt(u.id) !== null) return false;
writeCardTrialPrompt(u.id, 'shown');
- router.push('/start-trial');
+ router.push(startTrialPath('signup_prompt'));
return true;
};
diff --git a/packages/frontend/src/app/settings/license/page.tsx b/packages/frontend/src/app/settings/license/page.tsx
index 392cbbd4..a0ef0c24 100644
--- a/packages/frontend/src/app/settings/license/page.tsx
+++ b/packages/frontend/src/app/settings/license/page.tsx
@@ -14,6 +14,7 @@ import { EditionCard } from '@/components/edition-card';
import { cardTrialDisplayEnd, cardTrialEligible, formatTrialEnd } from '@/lib/card-trial';
import { useCardTrialKind } from '@/lib/use-card-trial-eligibility';
import { describeBilling } from '@/lib/billing-summary';
+import { licensePageSource, startTrialPath } from '@/lib/checkout-source';
interface LicenseStatus {
plan: string | null;
@@ -36,7 +37,13 @@ export default function LicenseSettingsPage() {
const [verifying, setVerifying] = useState(false);
const [openingPortal, setOpeningPortal] = useState(false);
const managePlan = useManagePlan();
- const pricingUrl = usePricingUrl();
+ // Where the visitor came from (`?source=`, e.g. the offer email), passed on
+ // to the card trial and the pricing link for Stripe metadata.
+ const [source, setSource] = useState>('license_page');
+ useEffect(() => {
+ setSource(licensePageSource(new URLSearchParams(window.location.search).get('source')));
+ }, []);
+ const pricingUrl = usePricingUrl(undefined, source);
const { edition } = useEdition();
const isCloud = deploymentMode === 'cloud';
@@ -436,7 +443,7 @@ export default function LicenseSettingsPage() {
)}
-
+
{cardTrialKind === 'pay' ? 'Choose a plan' : 'Add payment method'}
(null);
// 'pay': this address had a subscription, so the plan starts and is billed today.
const [kind, setKind] = useState<'trial' | 'pay'>('trial');
- // Which in-app offer sent the user here (?source=first_success), for Stripe.
- const [source, setSource] = useState(null);
+ // Which entry point sent the user here (?source=…), for Stripe and the events.
+ const [source, setSource] = useState('start_trial');
+ const viewReported = useRef(false);
// Set when the user came from "Connect" in an AI client: every way out of
// this page goes back to that authorization.
const [authorizationReturn, setAuthorizationReturn] = useState(null);
@@ -93,8 +100,13 @@ export default function StartTrialPage() {
setTrialEnd(cardTrialDisplayEnd(lic.expiresAt));
setSelection(readPlanIntent() ?? DEFAULT_SELECTION);
setPromo(readPromoCode());
- setSource(parseCheckoutSource(new URLSearchParams(window.location.search).get('source')));
+ const from = parseCheckoutSource(new URLSearchParams(window.location.search).get('source')) ?? 'start_trial';
+ setSource(from);
setReady(true);
+ if (!viewReported.current) {
+ viewReported.current = true;
+ productEvents.track('start_trial_viewed', token, { source: from, kind: trialKind });
+ }
})
.catch(() => {
if (live) leave('/');
@@ -114,6 +126,7 @@ export default function StartTrialPage() {
window.location.assign(checkout.chargesTodayUrl);
return;
}
+ if (token) productEvents.track('start_trial_checkout_started', token, { source, kind: paysToday ? 'pay' : 'trial' });
const outcome = await checkout.start(selection, !paysToday, source);
if (outcome === 'redirected') writeCardTrialPrompt(user.id, 'checkout');
};
diff --git a/packages/frontend/src/components/license-wall.tsx b/packages/frontend/src/components/license-wall.tsx
index 5d8c17f0..52b44998 100644
--- a/packages/frontend/src/components/license-wall.tsx
+++ b/packages/frontend/src/components/license-wall.tsx
@@ -26,8 +26,9 @@ type BlockReason = 'no-license' | 'trial-ended' | 'expired' | 'lapsed' | 'paymen
export function LicenseWall() {
const { token, user, deploymentMode } = useAuth();
const managePlan = useManagePlan();
- const pricingUrl = usePricingUrl();
const [reason, setReason] = useState(null);
+ // The pricing links name the wall when it is the ended trial's.
+ const pricingUrl = usePricingUrl(undefined, reason === 'trial-ended' ? 'trial_ended_wall' : 'app_pricing');
const [starting, setStarting] = useState(false);
const [startErr, setStartErr] = useState(null);
const pathname = usePathname();
@@ -232,7 +233,7 @@ export function LicenseWall() {