diff --git a/apps/web/.env.example b/apps/web/.env.example
index 4ea84d0dc49..e7883eb7b3b 100644
--- a/apps/web/.env.example
+++ b/apps/web/.env.example
@@ -8,6 +8,8 @@ NEXTAUTH_URL=http://localhost:8888 # (only needed for localhost)
# Secret for Vercel cron jobs + sync-embeddings
CRON_SECRET=
+# Shared with the LoopWork demo cron. Mints geo-accurate clicks via POST /api/demo/click
+DEMO_CLICK_SECRET=
# Encryption key (AES-256-GCM) for encrypting sensitive data in the database
ENCRYPTION_KEY=
# Email unsubscribe token secret (optional, falls back to NEXTAUTH_SECRET)
diff --git a/apps/web/app/(ee)/api/demo/click/route.ts b/apps/web/app/(ee)/api/demo/click/route.ts
new file mode 100644
index 00000000000..d35f90f1678
--- /dev/null
+++ b/apps/web/app/(ee)/api/demo/click/route.ts
@@ -0,0 +1,107 @@
+import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors";
+import { parseRequestBody } from "@/lib/api/utils";
+import { prefixWorkspaceId } from "@/lib/api/workspaces/workspace-id";
+import { withAxiom } from "@/lib/axiom/server";
+import { getLinkWithPartner } from "@/lib/planetscale/get-link-with-partner";
+import { recordFakeClick } from "@/lib/tinybird/record-fake-click";
+import {
+ COUNTRY_CODES,
+ DEMO_PROGRAM_ID,
+ DEMO_WORKSPACE_ID,
+ getDomainWithoutWWW,
+} from "@dub/utils";
+import { NextResponse } from "next/server";
+import * as z from "zod/v4";
+
+const demoClickSchema = z.object({
+ domain: z.preprocess(
+ (val) => getDomainWithoutWWW(val as string),
+ z.string({ error: "domain is required." }),
+ ),
+ key: z.string({ error: "key is required." }),
+ country: z.enum(COUNTRY_CODES),
+ region: z.string().nullish(),
+ city: z.string().nullish(),
+ continent: z.string().nullish(),
+ referrer: z.string().nullish(),
+ userAgent: z.string().nullish(),
+});
+
+function verifyDemoClickSecret(req: Request) {
+ const secret = process.env.DEMO_CLICK_SECRET;
+ const authorization = req.headers.get("authorization");
+
+ if (!secret || authorization !== `Bearer ${secret}`) {
+ throw new DubApiError({
+ code: "unauthorized",
+ message: "Invalid or missing DEMO_CLICK_SECRET.",
+ });
+ }
+}
+
+// POST /api/demo/click – mint a geo-accurate click for the LoopWork demo workspace only
+export const POST = withAxiom(async (req) => {
+ try {
+ verifyDemoClickSecret(req);
+
+ const {
+ domain,
+ key,
+ country,
+ region,
+ city,
+ continent,
+ referrer,
+ userAgent,
+ } = demoClickSchema.parse(await parseRequestBody(req));
+
+ const link = await getLinkWithPartner({ domain, key });
+
+ if (!link) {
+ throw new DubApiError({
+ code: "not_found",
+ message: `Link not found for domain: ${domain} and key: ${key}.`,
+ });
+ }
+
+ if (
+ prefixWorkspaceId(link.projectId) !== prefixWorkspaceId(DEMO_WORKSPACE_ID)
+ ) {
+ throw new DubApiError({
+ code: "forbidden",
+ message: "This endpoint can only record clicks for the demo workspace.",
+ });
+ }
+
+ if (link.programId && link.programId !== DEMO_PROGRAM_ID) {
+ throw new DubApiError({
+ code: "forbidden",
+ message: "This endpoint can only record clicks for the demo program.",
+ });
+ }
+
+ const clickEvent = await recordFakeClick({
+ link: {
+ id: link.id,
+ url: link.url,
+ domain: link.domain,
+ key: link.key,
+ projectId: link.projectId,
+ programId: link.programId,
+ partnerId: link.partnerId,
+ },
+ customer: {
+ country,
+ region,
+ city,
+ continent,
+ },
+ referrer,
+ userAgent,
+ });
+
+ return NextResponse.json({ clickId: clickEvent.click_id });
+ } catch (error) {
+ return handleAndReturnErrorResponse(error);
+ }
+});
diff --git a/apps/web/lib/api/commissions/create-manual-commissions.ts b/apps/web/lib/api/commissions/create-manual-commissions.ts
index 6998a52aa90..faccb29afab 100644
--- a/apps/web/lib/api/commissions/create-manual-commissions.ts
+++ b/apps/web/lib/api/commissions/create-manual-commissions.ts
@@ -447,9 +447,13 @@ async function recordEvents(args: RecordEventsArgs) {
timestamp: clickedAt.toISOString(),
identity_hash: targetCustomer.externalId || targetCustomer.id,
click_id: clickId,
+ workspace_id: workspace.id,
link_id: targetLink.id,
+ domain: targetLink.domain,
+ key: targetLink.key,
url: targetLink.url,
ip: "127.0.0.1",
+ country: targetCustomer.country || "Unknown",
continent: targetCustomer.country
? COUNTRIES_TO_CONTINENTS[targetCustomer.country.toUpperCase()] || ""
: "",
diff --git a/apps/web/lib/tinybird/record-fake-click.ts b/apps/web/lib/tinybird/record-fake-click.ts
index 76464382dd6..7461969f92b 100644
--- a/apps/web/lib/tinybird/record-fake-click.ts
+++ b/apps/web/lib/tinybird/record-fake-click.ts
@@ -1,4 +1,4 @@
-import { nanoid } from "@dub/utils";
+import { COUNTRIES_TO_CONTINENTS, nanoid } from "@dub/utils";
import { Link } from "@prisma/client";
import { clickEventSchemaTB } from "../zod/schemas/clicks";
import { recordClick } from "./record-click";
@@ -25,22 +25,50 @@ export async function recordFakeClick({
link,
customer,
timestamp,
+ referrer,
+ userAgent,
}: {
- link: Pick;
+ link: Pick & {
+ programId?: string | null;
+ partnerId?: string | null;
+ };
customer?: {
country?: string | null;
region?: string | null;
continent?: string | null;
+ city?: string | null;
+ latitude?: string | null;
+ longitude?: string | null;
};
timestamp?: string | number;
+ referrer?: string | null;
+ userAgent?: string | null;
}) {
+ const country = toSafeHeaderValue(customer?.country) || "US";
+ const continent =
+ toSafeHeaderValue(customer?.continent) ||
+ COUNTRIES_TO_CONTINENTS[country] ||
+ "NA";
+
const dummyRequest = new Request(link.url, {
headers: new Headers({
- "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
+ "user-agent":
+ userAgent || "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"x-forwarded-for": "127.0.0.1",
- "x-vercel-ip-country": toSafeHeaderValue(customer?.country) || "US",
+ "x-vercel-ip-country": country,
"x-vercel-ip-country-region": toSafeHeaderValue(customer?.region) || "CA",
- "x-vercel-ip-continent": toSafeHeaderValue(customer?.continent) || "NA",
+ "x-vercel-ip-continent": continent,
+ ...(customer?.city && {
+ "x-vercel-ip-city": toSafeHeaderValue(customer.city) || "Unknown",
+ }),
+ ...(customer?.latitude && {
+ "x-vercel-ip-latitude":
+ toSafeHeaderValue(customer.latitude) || "Unknown",
+ }),
+ ...(customer?.longitude && {
+ "x-vercel-ip-longitude":
+ toSafeHeaderValue(customer.longitude) || "Unknown",
+ }),
}),
});
@@ -52,8 +80,11 @@ export async function recordFakeClick({
domain: link.domain,
key: link.key,
url: link.url,
+ programId: link.programId ?? undefined,
+ partnerId: link.partnerId ?? undefined,
skipRatelimit: true,
shouldCacheClickId: true,
+ ...(referrer && { referrer }),
...(timestamp && { timestamp: new Date(timestamp).toISOString() }),
});