diff --git a/apps/web/__tests__/unit/checkout-recovery-webhook.test.ts b/apps/web/__tests__/unit/checkout-recovery-webhook.test.ts
new file mode 100644
index 00000000000..4287eee9281
--- /dev/null
+++ b/apps/web/__tests__/unit/checkout-recovery-webhook.test.ts
@@ -0,0 +1,276 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mockDbChain = {
+ select: vi.fn(),
+ from: vi.fn(),
+ where: vi.fn(),
+ limit: vi.fn(),
+ insert: vi.fn(),
+ update: vi.fn(),
+ set: vi.fn(),
+ values: vi.fn(),
+};
+
+/** Rows `findUserWithRetry` should resolve to, in call order. */
+let userLookupResults: Array>> = [];
+
+function resetDbChain() {
+ for (const key of Object.keys(mockDbChain)) {
+ mockDbChain[key as keyof typeof mockDbChain].mockClear();
+ }
+ mockDbChain.select.mockReturnValue(mockDbChain);
+ mockDbChain.from.mockReturnValue(mockDbChain);
+ mockDbChain.where.mockReturnValue(mockDbChain);
+ mockDbChain.limit.mockImplementation(() =>
+ Promise.resolve(userLookupResults.shift() ?? []),
+ );
+ mockDbChain.insert.mockReturnValue(mockDbChain);
+ mockDbChain.update.mockReturnValue(mockDbChain);
+ mockDbChain.set.mockReturnValue(mockDbChain);
+ mockDbChain.values.mockReturnValue(Promise.resolve());
+}
+
+const sendEmail = vi.fn().mockResolvedValue(undefined);
+const trackServerEvent = vi.fn().mockResolvedValue(undefined);
+
+vi.mock("@cap/database", () => ({ db: () => mockDbChain }));
+vi.mock("@cap/database/helpers", () => ({
+ nanoId: vi.fn(() => "test-nano-id"),
+}));
+vi.mock("@cap/database/emails/config", () => ({ sendEmail }));
+vi.mock("@cap/database/emails/payment-failed", () => ({
+ PaymentFailed: vi.fn(() => null),
+}));
+vi.mock("@cap/database/emails/checkout-recovery", () => ({
+ CheckoutRecovery: vi.fn((props: unknown) => props),
+}));
+vi.mock("@cap/database/schema", () => ({
+ developerCreditTransactions: {},
+ signedBaas: {},
+ users: { id: "id", email: "email" },
+}));
+vi.mock("@cap/env", () => ({
+ buildEnv: {},
+ serverEnv: () => ({
+ STRIPE_WEBHOOK_SECRET: "whsec_test",
+ WEB_URL: "https://cap.so",
+ }),
+}));
+vi.mock("@/lib/developer-credits", () => ({ addCreditsToAccount: vi.fn() }));
+vi.mock("@cap/web-domain", () => ({
+ Organisation: { OrganisationId: { make: (v: string) => v } },
+ User: { UserId: { make: (v: string) => v } },
+}));
+
+const mockStripe = {
+ webhooks: { constructEvent: vi.fn() },
+ customers: { retrieve: vi.fn() },
+ subscriptions: { retrieve: vi.fn(), list: vi.fn(), cancel: vi.fn() },
+};
+
+vi.mock("@cap/utils", () => ({
+ stripe: () => mockStripe,
+ userIsPro: (user?: { stripeSubscriptionStatus?: string | null } | null) =>
+ user?.stripeSubscriptionStatus === "active",
+ STRIPE_PLAN_IDS: {
+ development: { yearly: "price_dev_yearly", monthly: "price_dev_monthly" },
+ production: { yearly: "price_prod_yearly", monthly: "price_prod_monthly" },
+ },
+}));
+
+vi.mock("drizzle-orm", () => ({
+ and: vi.fn((...args: unknown[]) => args),
+ eq: vi.fn((a: unknown, b: unknown) => ({ eq: [a, b] })),
+ isNull: vi.fn((a: unknown) => ({ isNull: a })),
+ or: vi.fn((...args: unknown[]) => args),
+}));
+
+vi.mock("@/lib/server-analytics", () => ({ trackServerEvent }));
+
+function makeWebhookRequest() {
+ return new Request("https://cap.test/api/webhooks/stripe", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Stripe-Signature": "sig_test",
+ },
+ body: "{}",
+ });
+}
+
+function expiredSession(overrides: Record = {}) {
+ return {
+ type: "checkout.session.expired",
+ // 2026-08-20T12:00:00Z
+ created: 1787227200,
+ data: {
+ object: {
+ id: "cs_expired_1",
+ mode: "subscription",
+ customer: "cus_1",
+ customer_details: { email: "abandoner@example.com" },
+ metadata: { platform: "web", priceId: "price_dev_monthly" },
+ after_expiration: {
+ recovery: { url: "https://pay.cap.so/recover/cs_expired_1" },
+ },
+ ...overrides,
+ },
+ },
+ };
+}
+
+describe("Stripe webhook — abandoned checkout recovery", () => {
+ let POST: typeof import("@/app/api/webhooks/stripe/route").POST;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ userLookupResults = [];
+ resetDbChain();
+ mockStripe.customers.retrieve.mockResolvedValue({
+ id: "cus_1",
+ email: "abandoner@example.com",
+ metadata: { userId: "user-1" },
+ });
+ const mod = await import("@/app/api/webhooks/stripe/route");
+ POST = mod.POST;
+ });
+
+ it("emails the recovery link to a free user who abandoned checkout", async () => {
+ userLookupResults = [
+ [
+ {
+ id: "user-1",
+ email: "abandoner@example.com",
+ stripeSubscriptionStatus: null,
+ },
+ ],
+ ];
+ mockStripe.webhooks.constructEvent.mockReturnValue(expiredSession());
+
+ const res = await POST(makeWebhookRequest());
+
+ expect(res.status).toBe(200);
+ expect(sendEmail).toHaveBeenCalledTimes(1);
+ const call = sendEmail.mock.calls[0]?.[0];
+ expect(call.email).toBe("abandoner@example.com");
+ expect(call.react).toMatchObject({
+ recoveryUrl: "https://pay.cap.so/recover/cs_expired_1",
+ interval: "month",
+ });
+ // Day-bucketed so several abandoned sessions in one day send one email.
+ expect(call.idempotencyKey).toBe("checkout-recovery-user-1-2026-08-20");
+ expect(trackServerEvent).toHaveBeenCalledWith(
+ "user-1",
+ "checkout_recovery_email_sent",
+ expect.objectContaining({ interval: "month" }),
+ );
+ });
+
+ it("does not email a user who is already on a paid plan", async () => {
+ userLookupResults = [
+ [
+ {
+ id: "user-1",
+ email: "abandoner@example.com",
+ stripeSubscriptionStatus: "active",
+ },
+ ],
+ ];
+ mockStripe.webhooks.constructEvent.mockReturnValue(expiredSession());
+
+ const res = await POST(makeWebhookRequest());
+
+ expect(res.status).toBe(200);
+ expect(sendEmail).not.toHaveBeenCalled();
+ });
+
+ it("never cold-emails someone without a Cap account", async () => {
+ userLookupResults = [[], []];
+ mockStripe.customers.retrieve.mockResolvedValue({
+ id: "cus_1",
+ email: "stranger@example.com",
+ metadata: {},
+ });
+ mockStripe.webhooks.constructEvent.mockReturnValue(expiredSession());
+
+ const res = await POST(makeWebhookRequest());
+
+ expect(res.status).toBe(200);
+ expect(sendEmail).not.toHaveBeenCalled();
+ });
+
+ it("ignores expired sessions with no recovery URL", async () => {
+ mockStripe.webhooks.constructEvent.mockReturnValue(
+ expiredSession({ after_expiration: null }),
+ );
+
+ const res = await POST(makeWebhookRequest());
+
+ expect(res.status).toBe(200);
+ expect(sendEmail).not.toHaveBeenCalled();
+ expect(mockStripe.customers.retrieve).not.toHaveBeenCalled();
+ });
+
+ it("ignores non-Pro checkouts that expire", async () => {
+ for (const overrides of [
+ { mode: "payment" },
+ { metadata: { type: "developer_credits" } },
+ { metadata: { type: "signed_baa" } },
+ ]) {
+ vi.clearAllMocks();
+ mockStripe.webhooks.constructEvent.mockReturnValue(
+ expiredSession(overrides),
+ );
+
+ const res = await POST(makeWebhookRequest());
+
+ expect(res.status).toBe(200);
+ expect(sendEmail).not.toHaveBeenCalled();
+ }
+ });
+
+ it("does not count an email Resend suppressed as a duplicate", async () => {
+ userLookupResults = [
+ [
+ {
+ id: "user-1",
+ email: "abandoner@example.com",
+ stripeSubscriptionStatus: null,
+ },
+ ],
+ ];
+ // Resend reports a reused idempotency key in the body, it does not throw.
+ sendEmail.mockResolvedValueOnce({
+ data: null,
+ error: { message: "Idempotency key already used" },
+ });
+ mockStripe.webhooks.constructEvent.mockReturnValue(expiredSession());
+
+ const res = await POST(makeWebhookRequest());
+
+ expect(res.status).toBe(200);
+ expect(trackServerEvent).not.toHaveBeenCalledWith(
+ "user-1",
+ "checkout_recovery_email_sent",
+ expect.anything(),
+ );
+ });
+
+ it("keeps the webhook healthy when the email provider rejects the send", async () => {
+ userLookupResults = [
+ [
+ {
+ id: "user-1",
+ email: "abandoner@example.com",
+ stripeSubscriptionStatus: null,
+ },
+ ],
+ ];
+ sendEmail.mockRejectedValueOnce(new Error("duplicate idempotency key"));
+ mockStripe.webhooks.constructEvent.mockReturnValue(expiredSession());
+
+ const res = await POST(makeWebhookRequest());
+
+ expect(res.status).toBe(200);
+ });
+});
diff --git a/apps/web/__tests__/unit/developer-credits-webhook.test.ts b/apps/web/__tests__/unit/developer-credits-webhook.test.ts
index f379b56c859..853d114a47e 100644
--- a/apps/web/__tests__/unit/developer-credits-webhook.test.ts
+++ b/apps/web/__tests__/unit/developer-credits-webhook.test.ts
@@ -76,6 +76,18 @@ const mockStripe = {
vi.mock("@cap/utils", () => ({
stripe: () => mockStripe,
+ userIsPro: (user?: { stripeSubscriptionStatus?: string | null } | null) =>
+ user?.stripeSubscriptionStatus === "active",
+ STRIPE_PLAN_IDS: {
+ development: {
+ yearly: "price_dev_yearly",
+ monthly: "price_dev_monthly",
+ },
+ production: {
+ yearly: "price_prod_yearly",
+ monthly: "price_prod_monthly",
+ },
+ },
}));
vi.mock("drizzle-orm", () => ({
diff --git a/apps/web/__tests__/unit/mobile-checkout.test.ts b/apps/web/__tests__/unit/mobile-checkout.test.ts
index 5eecd2e22c0..da143bfcad4 100644
--- a/apps/web/__tests__/unit/mobile-checkout.test.ts
+++ b/apps/web/__tests__/unit/mobile-checkout.test.ts
@@ -9,8 +9,15 @@ import {
const checkoutMocks = vi.hoisted(() => ({
create: vi.fn(),
track: vi.fn(() => Promise.resolve()),
+ rateLimited: vi.fn(() => Promise.resolve(false)),
+ listPromotionCodes: vi.fn(),
}));
+// Matches STRIPE_PLAN_IDS.development, which is what the allowlist resolves to
+// when VERCEL_ENV is unset (as it is under test).
+const DEV_MONTHLY = "price_1P9C1DFJxA1XpeSsTwwuddnq";
+const DEV_YEARLY = "price_1Q3esrFJxA1XpeSsFwp486RN";
+
vi.mock("@cap/env", () => ({
buildEnv: {},
serverEnv: () => ({ WEB_URL: "https://cap.so" }),
@@ -21,7 +28,23 @@ vi.mock("@cap/utils", () => ({
checkout: {
sessions: { create: checkoutMocks.create },
},
+ promotionCodes: { list: checkoutMocks.listPromotionCodes },
}),
+ STRIPE_PLAN_IDS: {
+ development: {
+ yearly: "price_1Q3esrFJxA1XpeSsFwp486RN",
+ monthly: "price_1P9C1DFJxA1XpeSsTwwuddnq",
+ },
+ production: {
+ yearly: "price_1S2al7FJxA1XpeSsJCI5Z2UD",
+ monthly: "price_1S2akxFJxA1XpeSsfoAUUbpJ",
+ },
+ },
+}));
+
+vi.mock("@/lib/rate-limit", () => ({
+ isRateLimited: checkoutMocks.rateLimited,
+ RATE_LIMIT_IDS: { GUEST_CHECKOUT: "rl_guest_checkout" },
}));
vi.mock("@/lib/server-analytics", () => ({
@@ -42,6 +65,10 @@ describe("checkout redirects", () => {
id: "cs_test",
url: "https://pay.cap.so/session",
});
+ checkoutMocks.rateLimited.mockResolvedValue(false);
+ checkoutMocks.listPromotionCodes.mockResolvedValue({
+ data: [{ id: "promo_migrate20" }],
+ });
});
it("preserves the existing desktop checkout redirects", () => {
@@ -61,24 +88,134 @@ describe("checkout redirects", () => {
it("keeps existing guest checkout requests on the web flow", async () => {
const response = await startGuestCheckout(
- makeGuestCheckoutRequest({ priceId: "price_pro", quantity: 1 }),
+ makeGuestCheckoutRequest({ priceId: DEV_MONTHLY, quantity: 1 }),
);
expect(response.status).toBe(200);
expect(checkoutMocks.create).toHaveBeenCalledWith({
- line_items: [{ price: "price_pro", quantity: 1 }],
+ line_items: [{ price: DEV_MONTHLY, quantity: 1 }],
mode: "subscription",
success_url:
"https://cap.so/dashboard/caps?upgrade=true&guest=true&session_id={CHECKOUT_SESSION_ID}",
cancel_url: "https://cap.so/pricing",
allow_promotion_codes: true,
+ after_expiration: {
+ recovery: { enabled: true, allow_promotion_codes: true },
+ },
metadata: {
platform: "web",
guestCheckout: "true",
+ priceId: DEV_MONTHLY,
},
});
});
+ it("refuses price ids the pricing page does not offer", async () => {
+ // The account still carries retired cheaper plans (legacy $9/mo, $72/yr);
+ // an unauthenticated caller must not be able to subscribe at one.
+ const response = await startGuestCheckout(
+ makeGuestCheckoutRequest({
+ priceId: "price_1Q29mcFJxA1XpeSsbti0xJpZ",
+ quantity: 1,
+ }),
+ );
+
+ expect(response.status).toBe(400);
+ expect(checkoutMocks.create).not.toHaveBeenCalled();
+ });
+
+ it("rejects quantities outside the supported seat range", async () => {
+ for (const quantity of [0, -1, 101, 2.5]) {
+ const response = await startGuestCheckout(
+ makeGuestCheckoutRequest({ priceId: DEV_MONTHLY, quantity }),
+ );
+
+ expect(response.status).toBe(400);
+ }
+
+ expect(checkoutMocks.create).not.toHaveBeenCalled();
+ });
+
+ it("defaults a missing quantity to a single seat", async () => {
+ const response = await startGuestCheckout(
+ makeGuestCheckoutRequest({ priceId: DEV_MONTHLY }),
+ );
+
+ expect(response.status).toBe(200);
+ expect(checkoutMocks.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ line_items: [{ price: DEV_MONTHLY, quantity: 1 }],
+ }),
+ );
+ });
+
+ it("stops minting Stripe sessions once the caller is rate limited", async () => {
+ checkoutMocks.rateLimited.mockResolvedValue(true);
+
+ const response = await startGuestCheckout(
+ makeGuestCheckoutRequest({ priceId: DEV_MONTHLY, quantity: 1 }),
+ );
+
+ expect(response.status).toBe(429);
+ expect(checkoutMocks.create).not.toHaveBeenCalled();
+ });
+
+ it("applies an allowlisted campaign code from the URL", async () => {
+ const response = await startGuestCheckout(
+ makeGuestCheckoutRequest({
+ priceId: DEV_MONTHLY,
+ quantity: 1,
+ promoCode: "MIGRATE20",
+ }),
+ );
+
+ expect(response.status).toBe(200);
+ expect(checkoutMocks.listPromotionCodes).toHaveBeenCalledWith({
+ code: "MIGRATE20",
+ active: true,
+ limit: 1,
+ });
+ const params = checkoutMocks.create.mock.calls[0]?.[0];
+ expect(params.discounts).toEqual([{ promotion_code: "promo_migrate20" }]);
+ // Stripe rejects discounts and allow_promotion_codes together.
+ expect(params.allow_promotion_codes).toBeUndefined();
+ });
+
+ it("ignores promo codes that are not on the allowlist", async () => {
+ // The account carries active unrestricted 100%-off codes, so honouring an
+ // arbitrary ?promo= would hand out free Cap Pro.
+ const response = await startGuestCheckout(
+ makeGuestCheckoutRequest({
+ priceId: DEV_MONTHLY,
+ quantity: 1,
+ promoCode: "RICHIEGIFT",
+ }),
+ );
+
+ expect(response.status).toBe(200);
+ expect(checkoutMocks.listPromotionCodes).not.toHaveBeenCalled();
+ const params = checkoutMocks.create.mock.calls[0]?.[0];
+ expect(params.discounts).toBeUndefined();
+ expect(params.allow_promotion_codes).toBe(true);
+ });
+
+ it("falls back to manual entry when the campaign code is no longer active", async () => {
+ checkoutMocks.listPromotionCodes.mockResolvedValue({ data: [] });
+
+ const response = await startGuestCheckout(
+ makeGuestCheckoutRequest({
+ priceId: DEV_MONTHLY,
+ quantity: 1,
+ promoCode: "migrate20",
+ }),
+ );
+
+ expect(response.status).toBe(200);
+ const params = checkoutMocks.create.mock.calls[0]?.[0];
+ expect(params.discounts).toBeUndefined();
+ expect(params.allow_promotion_codes).toBe(true);
+ });
+
it("sends mobile checkout results through the HTTPS completion route", () => {
expect(getCheckoutRedirectUrls("mobile", "https://cap.so/")).toEqual({
successUrl: "https://cap.so/mobile/checkout/complete?checkout=success",
@@ -89,7 +226,7 @@ describe("checkout redirects", () => {
it("uses the app return only when guest checkout is explicitly mobile", async () => {
const response = await startGuestCheckout(
makeGuestCheckoutRequest({
- priceId: "price_pro",
+ priceId: DEV_YEARLY,
quantity: 1,
platform: "mobile",
}),
@@ -104,6 +241,7 @@ describe("checkout redirects", () => {
metadata: {
platform: "mobile",
guestCheckout: "true",
+ priceId: DEV_YEARLY,
},
}),
);
diff --git a/apps/web/__tests__/unit/signed-baa-webhook.test.ts b/apps/web/__tests__/unit/signed-baa-webhook.test.ts
index 3d4337823f4..d6fa375d646 100644
--- a/apps/web/__tests__/unit/signed-baa-webhook.test.ts
+++ b/apps/web/__tests__/unit/signed-baa-webhook.test.ts
@@ -86,6 +86,18 @@ const mockStripe = {
vi.mock("@cap/utils", () => ({
stripe: () => mockStripe,
+ userIsPro: (user?: { stripeSubscriptionStatus?: string | null } | null) =>
+ user?.stripeSubscriptionStatus === "active",
+ STRIPE_PLAN_IDS: {
+ development: {
+ yearly: "price_dev_yearly",
+ monthly: "price_dev_monthly",
+ },
+ production: {
+ yearly: "price_prod_yearly",
+ monthly: "price_prod_monthly",
+ },
+ },
}));
vi.mock("drizzle-orm", () => ({
diff --git a/apps/web/app/(site)/features/FeaturesPage.tsx b/apps/web/app/(site)/features/FeaturesPage.tsx
index a1f5c26110f..4d5d1f3d79c 100644
--- a/apps/web/app/(site)/features/FeaturesPage.tsx
+++ b/apps/web/app/(site)/features/FeaturesPage.tsx
@@ -262,7 +262,8 @@ const features: Feature[] = [
{
icon: faMobileAlt,
title: "Cross-Platform",
- description: "Native apps for macOS (Apple Silicon & Intel) and Windows",
+ description:
+ "Native apps for macOS (Apple Silicon & Intel), Windows and Linux, plus a Chrome extension and web recorder",
category: "platform",
size: "medium",
},
diff --git a/apps/web/app/(site)/tools/loom-downloader/page.tsx b/apps/web/app/(site)/tools/loom-downloader/page.tsx
index c0b20fe3dea..63380741e86 100644
--- a/apps/web/app/(site)/tools/loom-downloader/page.tsx
+++ b/apps/web/app/(site)/tools/loom-downloader/page.tsx
@@ -146,6 +146,26 @@ const pageContent: ToolPageContent = {
'Cap is the open source alternative to Loom. It\'s a privacy-focused screen recorder that lets you record, edit, and share videos instantly — with unlimited storage, custom domains, and a built-in Loom video importer. Download Cap for free.',
},
],
+ howTo: {
+ name: "How to download a Loom video",
+ description:
+ "Download any public Loom video as an MP4 in your browser, for free and without an account.",
+ totalTime: "PT1M",
+ steps: [
+ {
+ name: "Copy the Loom video link",
+ text: "Open the Loom video and click the share button, then copy the video URL.",
+ },
+ {
+ name: "Paste the link into the downloader",
+ text: "Paste the Loom URL into the input box on this page.",
+ },
+ {
+ name: "Download the MP4",
+ text: 'Click "Download Video" and the MP4 saves straight to your device. No signup, no watermark, no limit on how many you download.',
+ },
+ ],
+ },
cta: {
title: "Ready to leave Loom for good?",
description:
diff --git a/apps/web/app/api/desktop/[...route]/root.ts b/apps/web/app/api/desktop/[...route]/root.ts
index 8101878a72f..e2ed5d9148b 100644
--- a/apps/web/app/api/desktop/[...route]/root.ts
+++ b/apps/web/app/api/desktop/[...route]/root.ts
@@ -782,7 +782,17 @@ app.post(
success_url: redirects.successUrl,
cancel_url: redirects.cancelUrl,
allow_promotion_codes: true,
- metadata: { platform: checkoutPlatform, dubCustomerId: user.id },
+ // Attaches a recovery URL to `checkout.session.expired` so abandoned
+ // upgrades can be emailed back (handled in the Stripe webhook).
+ after_expiration: {
+ recovery: { enabled: true, allow_promotion_codes: true },
+ },
+ // `priceId` is read back on `checkout.session.expired` for the recovery email.
+ metadata: {
+ platform: checkoutPlatform,
+ dubCustomerId: user.id,
+ priceId,
+ },
});
if (checkoutSession.url) {
diff --git a/apps/web/app/api/settings/billing/guest-checkout/route.ts b/apps/web/app/api/settings/billing/guest-checkout/route.ts
index 6726ae711c1..9a26c4f0090 100644
--- a/apps/web/app/api/settings/billing/guest-checkout/route.ts
+++ b/apps/web/app/api/settings/billing/guest-checkout/route.ts
@@ -1,48 +1,103 @@
import { serverEnv } from "@cap/env";
-import { stripe } from "@cap/utils";
+import { STRIPE_PLAN_IDS, stripe } from "@cap/utils";
import type { NextRequest } from "next/server";
+import {
+ checkoutDiscountParams,
+ resolveUrlPromotionCodeId,
+} from "@/lib/checkout-promos";
import { getCheckoutRedirectUrls } from "@/lib/mobile-checkout";
+import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit";
import { trackServerEvent } from "@/lib/server-analytics";
+const MAX_QUANTITY = 100;
+
+/**
+ * Prices this unauthenticated endpoint is allowed to sell.
+ *
+ * Without this the route would mint a Stripe Checkout Session for ANY price id
+ * on the account, which includes retired cheaper plans (e.g. the legacy $9/mo
+ * and $72/yr tiers) — an informed caller could simply subscribe at a price we
+ * no longer offer. Mirrors the ids handed to the client in
+ * `app/Layout/PublicPageProviders.tsx`, so it must use the same env condition.
+ */
+function allowedPriceIds(): Set {
+ const plans =
+ process.env.VERCEL_ENV === "production"
+ ? STRIPE_PLAN_IDS.production
+ : STRIPE_PLAN_IDS.development;
+ return new Set(Object.values(plans));
+}
+
export async function POST(request: NextRequest) {
- console.log("Starting guest checkout process");
- const { priceId, quantity, platform } = await request.json();
+ const { priceId, quantity, platform, promoCode } = await request.json();
const checkoutPlatform = platform === "mobile" ? "mobile" : "web";
- console.log("Received guest checkout request:", { priceId, quantity });
-
- if (!priceId) {
- console.error("Missing required priceId");
+ if (!priceId || typeof priceId !== "string") {
return Response.json({ error: "priceId is required" }, { status: 400 });
}
+ if (!allowedPriceIds().has(priceId)) {
+ console.error("Guest checkout rejected: price id not offered", { priceId });
+ return Response.json({ error: "Invalid priceId" }, { status: 400 });
+ }
+
+ const seats = quantity ?? 1;
+ if (
+ typeof seats !== "number" ||
+ !Number.isInteger(seats) ||
+ seats < 1 ||
+ seats > MAX_QUANTITY
+ ) {
+ return Response.json(
+ { error: `quantity must be an integer between 1 and ${MAX_QUANTITY}` },
+ { status: 400 },
+ );
+ }
+
+ // Unauthenticated + calls Stripe on every request, so it is trivially
+ // abusable: five burst days in Jul/Aug 2026 created ~4,900 sessions with no
+ // customer attached, which also made checkout conversion unmeasurable.
+ if (
+ await isRateLimited(RATE_LIMIT_IDS.GUEST_CHECKOUT, {
+ headers: request.headers,
+ })
+ ) {
+ return Response.json({ error: "Too many requests" }, { status: 429 });
+ }
+
try {
- console.log("Creating guest checkout session");
+ const promotionCodeId = await resolveUrlPromotionCodeId(promoCode);
const redirects = getCheckoutRedirectUrls(
checkoutPlatform,
serverEnv().WEB_URL,
);
const checkoutSession = await stripe().checkout.sessions.create({
- line_items: [{ price: priceId, quantity: quantity || 1 }],
+ line_items: [{ price: priceId, quantity: seats }],
mode: "subscription",
success_url: redirects.successUrl,
cancel_url: redirects.cancelUrl,
- allow_promotion_codes: true,
+ ...checkoutDiscountParams(promotionCodeId),
+ // Lets `checkout.session.expired` carry a recovery URL so abandoned
+ // checkouts can be emailed back to people who already have an account.
+ after_expiration: {
+ recovery: { enabled: true, allow_promotion_codes: true },
+ },
metadata: {
platform: checkoutPlatform,
guestCheckout: "true",
+ // Read back on `checkout.session.expired` to tailor the recovery email.
+ priceId,
+ ...(promotionCodeId ? { promoCode: String(promoCode) } : {}),
},
});
if (checkoutSession.url) {
- console.log("Successfully created guest checkout session");
-
trackServerEvent(
`guest-${checkoutSession.id}`,
"guest_checkout_started",
{
price_id: priceId,
- quantity: quantity || 1,
+ quantity: seats,
platform: checkoutPlatform,
session_id: checkoutSession.id,
},
diff --git a/apps/web/app/api/settings/billing/subscribe/route.ts b/apps/web/app/api/settings/billing/subscribe/route.ts
index 397662e77a2..b4b535c9fb7 100644
--- a/apps/web/app/api/settings/billing/subscribe/route.ts
+++ b/apps/web/app/api/settings/billing/subscribe/route.ts
@@ -6,12 +6,16 @@ import { stripe, userIsPro } from "@cap/utils";
import { eq } from "drizzle-orm";
import type { NextRequest } from "next/server";
import type Stripe from "stripe";
+import {
+ checkoutDiscountParams,
+ resolveUrlPromotionCodeId,
+} from "@/lib/checkout-promos";
import { trackServerEvent } from "@/lib/server-analytics";
export async function POST(request: NextRequest) {
const user = await getCurrentUser();
let customerId = user?.stripeCustomerId;
- const { priceId, quantity, isOnBoarding } = await request.json();
+ const { priceId, quantity, isOnBoarding, promoCode } = await request.json();
if (!priceId) {
console.error("Price ID not found");
@@ -63,6 +67,8 @@ export async function POST(request: NextRequest) {
customerId = customer.id;
}
+ const promotionCodeId = await resolveUrlPromotionCodeId(promoCode);
+
const checkoutSession = await stripe().checkout.sessions.create({
customer: customerId as string,
line_items: [{ price: priceId, quantity: quantity }],
@@ -73,11 +79,19 @@ export async function POST(request: NextRequest) {
cancel_url: isOnBoarding
? `${serverEnv().WEB_URL}/onboarding`
: `${serverEnv().WEB_URL}/pricing`,
- allow_promotion_codes: true,
+ ...checkoutDiscountParams(promotionCodeId),
+ // Attaches a recovery URL to `checkout.session.expired` so abandoned
+ // upgrades can be emailed back (handled in the Stripe webhook).
+ after_expiration: {
+ recovery: { enabled: true, allow_promotion_codes: true },
+ },
metadata: {
platform: "web",
dubCustomerId: user.id,
isOnBoarding: isOnBoarding ? "true" : "false",
+ // Read back on `checkout.session.expired` to tailor the recovery email.
+ priceId,
+ ...(promotionCodeId ? { promoCode: String(promoCode) } : {}),
},
});
diff --git a/apps/web/app/api/webhooks/stripe/route.ts b/apps/web/app/api/webhooks/stripe/route.ts
index afbeba3e002..334216f5215 100644
--- a/apps/web/app/api/webhooks/stripe/route.ts
+++ b/apps/web/app/api/webhooks/stripe/route.ts
@@ -1,4 +1,5 @@
import { db } from "@cap/database";
+import { CheckoutRecovery } from "@cap/database/emails/checkout-recovery";
import { sendEmail } from "@cap/database/emails/config";
import { PaymentFailed } from "@cap/database/emails/payment-failed";
import { nanoId } from "@cap/database/helpers";
@@ -8,7 +9,7 @@ import {
users,
} from "@cap/database/schema";
import { serverEnv } from "@cap/env";
-import { stripe } from "@cap/utils";
+import { STRIPE_PLAN_IDS, stripe, userIsPro } from "@cap/utils";
import { Organisation, User } from "@cap/web-domain";
import { and, eq, isNull, or } from "drizzle-orm";
import { NextResponse } from "next/server";
@@ -18,6 +19,7 @@ import { trackServerEvent } from "@/lib/server-analytics";
const relevantEvents = new Set([
"checkout.session.completed",
+ "checkout.session.expired",
"checkout.session.async_payment_succeeded",
"customer.subscription.created",
"customer.subscription.updated",
@@ -660,6 +662,116 @@ export const POST = async (req: Request) => {
});
}
+ if (event.type === "checkout.session.expired") {
+ const session = event.data.object as Stripe.Checkout.Session;
+
+ // Only sessions created with `after_expiration.recovery` carry a URL.
+ const recoveryUrl = session.after_expiration?.recovery?.url;
+ if (!recoveryUrl) {
+ return NextResponse.json({ received: true });
+ }
+
+ // Cap Pro upgrades only. Developer credits, Signed BAA and licence
+ // checkouts have their own flows and must not get an upgrade nudge.
+ if (
+ session.mode !== "subscription" ||
+ session.metadata?.type === "developer_credits" ||
+ session.metadata?.type === "signed_baa"
+ ) {
+ return NextResponse.json({ received: true });
+ }
+
+ let foundUserId: User.UserId | undefined;
+ let customerEmail: string | null | undefined =
+ session.customer_details?.email;
+
+ if (typeof session.customer === "string") {
+ const customer = await stripe().customers.retrieve(session.customer);
+ if ("metadata" in customer && customer.metadata.userId) {
+ foundUserId = User.UserId.make(customer.metadata.userId);
+ }
+ if ("email" in customer && customer.email) {
+ customerEmail = customer.email;
+ }
+ }
+
+ // Never cold-email. Only people who already have a Cap account get a
+ // nudge, which keeps this a transactional reminder about their own
+ // account rather than an unsolicited marketing send. No retry: an
+ // expired checkout is not a race with account creation.
+ const dbUser = await findUserWithRetry(
+ customerEmail ?? "",
+ foundUserId,
+ 1,
+ );
+ if (!dbUser?.email) {
+ console.log("Abandoned checkout has no Cap account; skipping email");
+ return NextResponse.json({ received: true });
+ }
+
+ // They may have completed a different session, or already be on a
+ // plan via a licence or an organisation seat.
+ if (userIsPro(dbUser)) {
+ return NextResponse.json({ received: true });
+ }
+
+ const plans =
+ process.env.VERCEL_ENV === "production"
+ ? STRIPE_PLAN_IDS.production
+ : STRIPE_PLAN_IDS.development;
+ const priceId = session.metadata?.priceId;
+ const interval =
+ priceId === plans.monthly
+ ? "month"
+ : priceId === plans.yearly
+ ? "year"
+ : null;
+
+ // One recovery email per user per day: a person who opens checkout
+ // three times generates three expired sessions, and Resend keys are
+ // retained for 24h, so the day bucket collapses them.
+ const dayBucket = new Date(event.created * 1000)
+ .toISOString()
+ .slice(0, 10);
+
+ try {
+ const sendResult = await sendEmail({
+ email: dbUser.email,
+ subject: "You didn't finish upgrading to Cap Pro",
+ react: CheckoutRecovery({
+ email: dbUser.email,
+ recoveryUrl,
+ interval,
+ }),
+ idempotencyKey: `checkout-recovery-${dbUser.id}-${dayBucket}`,
+ });
+
+ // Resend reports a reused idempotency key in the response body
+ // rather than throwing, so the send has to be confirmed before
+ // tracking or the readout counts emails that never went out.
+ const sendError = sendResult?.error;
+ if (sendError) {
+ console.log("Checkout recovery email suppressed", {
+ userId: dbUser.id,
+ reason: sendError.message,
+ });
+ } else {
+ trackServerEvent(dbUser.id, "checkout_recovery_email_sent", {
+ session_id: session.id,
+ price_id: priceId ?? null,
+ interval,
+ platform: session.metadata?.platform ?? null,
+ });
+
+ console.log("Checkout recovery email sent", { userId: dbUser.id });
+ }
+ } catch (error) {
+ // Swallowed so Stripe does not retry the whole webhook (and re-run
+ // the lookups) over a transient email-provider failure.
+ console.warn("Checkout recovery email not sent", error);
+ }
+ }
+
if (event.type === "customer.subscription.deleted") {
const subscription = event.data.object as Stripe.Subscription;
diff --git a/apps/web/components/pages/FaqPage.tsx b/apps/web/components/pages/FaqPage.tsx
index 87d7a19a1c4..2b1bae226cf 100644
--- a/apps/web/components/pages/FaqPage.tsx
+++ b/apps/web/components/pages/FaqPage.tsx
@@ -25,7 +25,7 @@ const faqContent: FaqItem[] = [
{
title: "Which platforms does Cap support?",
answer:
- "Cap is cross-platform and works on macOS (both Apple Silicon and Intel) and Windows. For macOS, we recommend version 13.1 or newer. For Windows, we recommend Windows 10 or newer.",
+ "Cap is cross-platform. There are native desktop apps for macOS (both Apple Silicon and Intel), Windows and Linux, plus a Google Chrome extension and a web recorder that runs straight from your browser. For macOS, we recommend version 13.1 or newer. For Windows, we recommend Windows 10 or newer.",
},
{
title: "What makes Cap different from Loom?",
diff --git a/apps/web/components/pages/HomePage/Pricing/ProCard.tsx b/apps/web/components/pages/HomePage/Pricing/ProCard.tsx
index 0bacf8e985a..d98f15b945f 100644
--- a/apps/web/components/pages/HomePage/Pricing/ProCard.tsx
+++ b/apps/web/components/pages/HomePage/Pricing/ProCard.tsx
@@ -4,6 +4,7 @@ import { Button } from "@cap/ui";
import NumberFlow from "@number-flow/react";
import { useMutation } from "@tanstack/react-query";
import { useCurrency } from "hooks/useCurrency";
+import { usePromoCode } from "hooks/usePromoCode";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { useStripeContext } from "@/app/Layout/StripeContext";
@@ -18,14 +19,24 @@ const copy = homepageCopy.pricing.pro;
export const ProCard = () => {
const stripeCtx = useStripeContext();
+ const { promoCode, promoPercentOff } = usePromoCode();
const { symbol } = useCurrency();
const [users, setUsers] = useState(1);
const [isAnnually, setIsAnnually] = useState(false);
const artRef = useRef(null);
- const perUser = isAnnually ? copy.pricing.annual : copy.pricing.monthly;
- const monthlyTotal = perUser * users;
- const yearlyTotal = Math.round(copy.pricing.annual * 12) * users;
+ const round2 = (value: number) => Math.round(value * 100) / 100;
+ // A discounted price is rarely a whole number, and NumberFlow would render
+ // 9.6 rather than 9.60 without this. List prices keep their existing format.
+ const priceFormat =
+ promoPercentOff > 0 ? { minimumFractionDigits: 2 } : undefined;
+ const promoFactor = (100 - promoPercentOff) / 100;
+ const listPerUser = isAnnually ? copy.pricing.annual : copy.pricing.monthly;
+ const perUser = round2(listPerUser * promoFactor);
+ const monthlyTotal = round2(perUser * users);
+ const yearlyTotal = round2(
+ Math.round(copy.pricing.annual * 12) * promoFactor * users,
+ );
const incrementUsers = () => setUsers((prev) => prev + 1);
const decrementUsers = () => setUsers((prev) => (prev > 1 ? prev - 1 : 1));
@@ -37,7 +48,7 @@ export const ProCard = () => {
headers: {
"Content-Type": "application/json",
},
- body: JSON.stringify({ priceId: planId, quantity: users }),
+ body: JSON.stringify({ priceId: planId, quantity: users, promoCode }),
});
const data = await response.json();
@@ -61,7 +72,7 @@ export const ProCard = () => {
headers: {
"Content-Type": "application/json",
},
- body: JSON.stringify({ priceId: planId, quantity: users }),
+ body: JSON.stringify({ priceId: planId, quantity: users, promoCode }),
});
const data = await response.json();
@@ -104,14 +115,28 @@ export const ProCard = () => {
+ {promoPercentOff > 0 && (
+
+ {symbol}
+ {listPerUser}
+
+ )}
{symbol}
-
+
/ user / month
billed {isAnnually ? "annually" : "monthly"}
+ {promoPercentOff > 0 && (
+ <>
+ {" · "}
+
+ {promoPercentOff}% off with {promoCode}
+
+ >
+ )}
@@ -136,7 +161,10 @@ export const ProCard = () => {
Total:{" "}
{symbol}
-
+
{" "}
{isAnnually ? "/ year" : "/ month"}
diff --git a/apps/web/components/pages/_components/ComparePlans.tsx b/apps/web/components/pages/_components/ComparePlans.tsx
index 92c06a81680..d0df6dc5b59 100644
--- a/apps/web/components/pages/_components/ComparePlans.tsx
+++ b/apps/web/components/pages/_components/ComparePlans.tsx
@@ -5,6 +5,7 @@ import { classNames } from "@cap/utils";
import { faCheck } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useCurrency } from "hooks/useCurrency";
+import { usePromoCode } from "hooks/usePromoCode";
import { Fragment, useMemo, useState } from "react";
import { toast } from "sonner";
import { useCurrentUser } from "@/app/Layout/AuthContext";
@@ -165,6 +166,7 @@ export const ComparePlans = () => {
const [guestLoading, setGuestLoading] = useState(false);
const [commercialLoading, setCommercialLoading] = useState(false);
const stripeCtx = useStripeContext();
+ const { promoCode } = usePromoCode();
const isDisabled = useMemo(
() =>
@@ -234,7 +236,7 @@ export const ComparePlans = () => {
const guestCheckout = (planId: string) =>
handleCheckout(
"/api/settings/billing/guest-checkout",
- { priceId: planId, quantity: 1 },
+ { priceId: planId, quantity: 1, promoCode },
setGuestLoading,
"Failed to create checkout session",
);
@@ -255,7 +257,7 @@ export const ComparePlans = () => {
const response = await fetch("/api/settings/billing/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ priceId: finalPlanId, quantity: 1 }),
+ body: JSON.stringify({ priceId: finalPlanId, quantity: 1, promoCode }),
});
const data = await response.json();
diff --git a/apps/web/components/tools/LoomDownloader.tsx b/apps/web/components/tools/LoomDownloader.tsx
index bdbe7bc2fab..275092b8093 100644
--- a/apps/web/components/tools/LoomDownloader.tsx
+++ b/apps/web/components/tools/LoomDownloader.tsx
@@ -3,6 +3,7 @@
import { Button } from "@cap/ui";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { toast } from "sonner";
+import { trackEvent } from "@/app/utils/analytics";
import {
getLoomBrowserConversionErrorMessage,
getLoomBrowserConversionSupport,
@@ -67,6 +68,7 @@ function PromoCodeChip() {
try {
await navigator.clipboard.writeText(MIGRATE_PROMO_CODE);
setCopied(true);
+ trackEvent("loom_downloader_promo_copied", { code: MIGRATE_PROMO_CODE });
toast.success(`Code ${MIGRATE_PROMO_CODE} copied to clipboard`);
setTimeout(() => setCopied(false), 2000);
} catch {
@@ -116,6 +118,12 @@ function MigrationBanner() {
size="sm"
href={MIGRATE_CHECKOUT_HREF}
className="whitespace-nowrap"
+ onClick={() =>
+ trackEvent("loom_downloader_cta_clicked", {
+ target: "migrate",
+ placement: "banner",
+ })
+ }
>
Switch to Cap
@@ -210,6 +218,12 @@ function MigrationSuccessState({
size="lg"
href={MIGRATE_CHECKOUT_HREF}
className="w-full sm:w-auto"
+ onClick={() =>
+ trackEvent("loom_downloader_cta_clicked", {
+ target: "migrate",
+ placement: "success",
+ })
+ }
>
Migrate with Cap Pro — save 20%
@@ -218,6 +232,12 @@ function MigrationSuccessState({
size="lg"
href="/download"
className="w-full sm:w-auto"
+ onClick={() =>
+ trackEvent("loom_downloader_cta_clicked", {
+ target: "download-app",
+ placement: "success",
+ })
+ }
>
Download Cap free
@@ -331,6 +351,9 @@ export function LoomDownloader() {
setLastCompletionKind("ready");
setLastDownloadedName(conversion.videoName);
setStatus("success");
+ trackEvent("loom_downloader_completed", {
+ mode: "browser-conversion",
+ });
} catch (err) {
if (
(err instanceof DOMException && err.name === "AbortError") ||
@@ -340,6 +363,7 @@ export function LoomDownloader() {
return;
}
+ trackEvent("loom_downloader_failed", { stage: "conversion" });
setStatus("error");
setErrorMessage(
getLoomBrowserConversionErrorMessage(err) ??
@@ -355,6 +379,7 @@ export function LoomDownloader() {
const handleDownload = useCallback(async () => {
if (!url.trim()) return;
+ trackEvent("loom_downloader_submitted");
setStatus("fetching");
setErrorMessage("");
setConvertProgress(0);
@@ -363,6 +388,7 @@ export function LoomDownloader() {
const result = await resolveLoomBrowserDownload(url.trim());
if (!result.success || !result.videoId) {
+ trackEvent("loom_downloader_failed", { stage: "resolve" });
setStatus("error");
setErrorMessage(result.error || "Something went wrong.");
return;
@@ -374,6 +400,7 @@ export function LoomDownloader() {
);
if (!result.downloadUrl) {
+ trackEvent("loom_downloader_failed", { stage: "no-download-url" });
setStatus("error");
setErrorMessage("Could not retrieve a video download URL.");
return;
@@ -386,11 +413,13 @@ export function LoomDownloader() {
setLastCompletionKind("download-started");
setLastDownloadedName(result.videoName ?? "");
setStatus("success");
+ trackEvent("loom_downloader_completed", { mode: "direct-download" });
return;
}
const support = getLoomBrowserConversionSupport();
if (!support.supported) {
+ trackEvent("loom_downloader_failed", { stage: "browser-unsupported" });
setStatus("error");
setErrorMessage(
support.message ??
@@ -424,6 +453,7 @@ export function LoomDownloader() {
}, [runBrowserConversion, updateDownloadObjectUrl, url]);
const handleDownloadAnother = useCallback(() => {
+ trackEvent("loom_downloader_reset");
setUrl("");
setStatus("idle");
setErrorMessage("");
diff --git a/apps/web/components/tools/ToolsPageTemplate.tsx b/apps/web/components/tools/ToolsPageTemplate.tsx
index f308b6b9377..ff695478a71 100644
--- a/apps/web/components/tools/ToolsPageTemplate.tsx
+++ b/apps/web/components/tools/ToolsPageTemplate.tsx
@@ -4,6 +4,7 @@ import { Button } from "@cap/ui";
import Link from "next/link";
import { type ReactNode, useEffect } from "react";
import type { ToolPageContent } from "@/components/tools/types";
+import { createFAQSchema, createHowToSchema } from "@/utils/web-schema";
const renderHTML = (content: string) => {
const styledContent = content.replace(
@@ -117,6 +118,24 @@ export const ToolsPageTemplate = ({
return (
<>
+ {content.faqs && content.faqs.length > 0 && (
+
+ )}
+ {content.howTo && (
+
+ )}
diff --git a/apps/web/components/tools/types.ts b/apps/web/components/tools/types.ts
index ab5ba02380d..ce348ff7d29 100644
--- a/apps/web/components/tools/types.ts
+++ b/apps/web/components/tools/types.ts
@@ -27,4 +27,16 @@ export interface ToolPageContent {
question: string;
answer: string;
}>;
+
+ /**
+ * Optional HowTo steps. Emitted as HowTo structured data by
+ * `ToolsPageTemplate`, which makes the page eligible for step-by-step rich
+ * results on "how do I ..." queries.
+ */
+ howTo?: {
+ name: string;
+ description: string;
+ totalTime?: string;
+ steps: Array<{ name: string; text: string }>;
+ };
}
diff --git a/apps/web/data/homepage-copy.ts b/apps/web/data/homepage-copy.ts
index 921b3f6cf67..fdbb9d4ec22 100644
--- a/apps/web/data/homepage-copy.ts
+++ b/apps/web/data/homepage-copy.ts
@@ -334,7 +334,7 @@ export const homepageCopy: HomePageCopy = {
"Everything in Desktop License",
"Unlimited cloud storage & bandwidth",
"Auto-generated titles, summaries, clickable chapters, and transcriptions for every recording",
- "SOC 2 Type II & ISO 27001 compliant",
+ "SOC 2 Type II, ISO 27001 & HIPAA compliant",
"Custom domain (cap.yourdomain.com)",
"Password protected shares",
"Viewer analytics & engagement",
@@ -396,7 +396,7 @@ export const homepageCopy: HomePageCopy = {
{
question: "Which platforms do you support?",
answer:
- "Native desktop apps for macOS (Apple Silicon & Intel) and Windows. View your shareable links from anywhere.",
+ "Native desktop apps for macOS (Apple Silicon & Intel), Windows, and Linux (.deb, .rpm, AppImage), plus a Google Chrome extension and a web recorder that runs straight from your browser. View your shareable links from anywhere.",
},
{
question: "Can I use Cap for commercial purposes?",
diff --git a/apps/web/hooks/usePromoCode.ts b/apps/web/hooks/usePromoCode.ts
new file mode 100644
index 00000000000..e15a799a1bd
--- /dev/null
+++ b/apps/web/hooks/usePromoCode.ts
@@ -0,0 +1,26 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { normalizeUrlPromoCode, URL_PROMO_CODES } from "@/lib/promo-codes";
+
+/**
+ * Reads an allowlisted `?promo=` campaign code off the current URL.
+ *
+ * Deliberately reads `window.location` in an effect rather than using
+ * `useSearchParams`, which would opt the statically rendered marketing pages
+ * into client-side rendering and cost them their prerendered HTML.
+ */
+export function usePromoCode() {
+ const [code, setCode] = useState
(null);
+
+ useEffect(() => {
+ const param = new URLSearchParams(window.location.search).get("promo");
+ setCode(normalizeUrlPromoCode(param));
+ }, []);
+
+ return {
+ promoCode: code,
+ promoLabel: code ? URL_PROMO_CODES[code]?.label : undefined,
+ promoPercentOff: code ? (URL_PROMO_CODES[code]?.percentOff ?? 0) : 0,
+ };
+}
diff --git a/apps/web/lib/checkout-promos.ts b/apps/web/lib/checkout-promos.ts
new file mode 100644
index 00000000000..5429c5ef8ca
--- /dev/null
+++ b/apps/web/lib/checkout-promos.ts
@@ -0,0 +1,39 @@
+import { stripe } from "@cap/utils";
+import { normalizeUrlPromoCode } from "@/lib/promo-codes";
+
+/**
+ * Resolves a campaign code to the Stripe promotion code id Checkout expects.
+ *
+ * Returns null for anything not on the allowlist, and for allowlisted codes
+ * that have since been deactivated or expired, so the caller falls back to the
+ * normal "enter a code yourself" checkout rather than failing the purchase.
+ */
+export async function resolveUrlPromotionCodeId(
+ value: unknown,
+): Promise {
+ const code = normalizeUrlPromoCode(value);
+ if (!code) return null;
+
+ try {
+ const codes = await stripe().promotionCodes.list({
+ code,
+ active: true,
+ limit: 1,
+ });
+ return codes.data[0]?.id ?? null;
+ } catch (error) {
+ console.error("Failed to resolve promotion code", error);
+ return null;
+ }
+}
+
+/**
+ * Stripe rejects `discounts` and `allow_promotion_codes` together, so a session
+ * either arrives with a campaign discount already applied or offers the promo
+ * input. Spread this into the session params.
+ */
+export function checkoutDiscountParams(promotionCodeId: string | null) {
+ return promotionCodeId
+ ? { discounts: [{ promotion_code: promotionCodeId }] }
+ : { allow_promotion_codes: true };
+}
diff --git a/apps/web/lib/promo-codes.ts b/apps/web/lib/promo-codes.ts
new file mode 100644
index 00000000000..0dd987f870f
--- /dev/null
+++ b/apps/web/lib/promo-codes.ts
@@ -0,0 +1,21 @@
+/**
+ * Campaign codes that may be applied straight from a `?promo=` URL parameter,
+ * shared by the client (to show the code is active) and the checkout routes
+ * (to actually apply it).
+ *
+ * This MUST stay an allowlist. The Stripe account carries several unrestricted
+ * 100%-off codes (staff gifts, internal tests), so honouring whatever a query
+ * string asks for would hand out free Cap Pro to anyone who guessed one.
+ */
+export const URL_PROMO_CODES: Record<
+ string,
+ { label: string; percentOff: number }
+> = {
+ MIGRATE20: { label: "20% off Cap Pro", percentOff: 20 },
+};
+
+export function normalizeUrlPromoCode(value: unknown): string | null {
+ if (typeof value !== "string") return null;
+ const code = value.trim().toUpperCase();
+ return code in URL_PROMO_CODES ? code : null;
+}
diff --git a/packages/database/emails/checkout-recovery.tsx b/packages/database/emails/checkout-recovery.tsx
new file mode 100644
index 00000000000..f4af7d74cd4
--- /dev/null
+++ b/packages/database/emails/checkout-recovery.tsx
@@ -0,0 +1,83 @@
+import { CAP_LOGO_URL } from "@cap/utils";
+import {
+ Body,
+ Container,
+ Head,
+ Heading,
+ Html,
+ Img,
+ Link,
+ Preview,
+ Section,
+ Tailwind,
+ Text,
+} from "@react-email/components";
+import Footer from "./components/Footer";
+
+export function CheckoutRecovery({
+ email = "",
+ recoveryUrl = "",
+ interval = null,
+}: {
+ email: string;
+ recoveryUrl: string;
+ interval?: "month" | "year" | null;
+}) {
+ return (
+
+
+ Your Cap Pro checkout is still waiting for you
+
+
+
+
+
+
+
+ You didn't finish upgrading
+
+
+ You started upgrading to Cap Pro but didn't get to the end. Your
+ checkout is still here, so you can pick up exactly where you left
+ off.
+
+
+ {interval === "month" ? (
+
+ One thing worth knowing: the yearly plan works out a lot cheaper
+ than paying monthly, and you can switch to it on the same
+ checkout page.
+
+ ) : null}
+
+ Pro gives you unlimited recording length, unlimited shareable
+ links, Cap AI summaries and titles, custom domains, and password
+ protected links.
+
+
+ If you've changed your mind that's completely fine, you can ignore
+ this email. If something went wrong at checkout, just reply and
+ we'll sort it out.
+
+
+
+
+
+
+ );
+}
+
+export default CheckoutRecovery;