diff --git a/.env.example b/.env.example
index 1cf16f0..d787eb9 100644
--- a/.env.example
+++ b/.env.example
@@ -19,6 +19,12 @@ COINPAY_BUSINESS_ID=00000000-0000-0000-0000-000000000000
COINPAY_WEBHOOK_SECRET=whsecret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
COINPAY_API_URL=https://coinpayportal.com
+# --- Optional: GitHub Marketplace listing webhook ---
+# Set on the listing's Webhook page (Payload URL + Secret), not in the App's
+# developer settings. Must match the Secret field byte for byte.
+# Generate with: openssl rand -hex 32
+GITHUB_MARKETPLACE_WEBHOOK_SECRET=
+
# ─── Optional: Web Push (VAPID keys for PWA push notifications) ───
# Generate with: npx web-push generate-vapid-keys
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
diff --git a/apps/web/src/app/admin/admin-content.tsx b/apps/web/src/app/admin/admin-content.tsx
index 1fb28c9..a3ab32d 100644
--- a/apps/web/src/app/admin/admin-content.tsx
+++ b/apps/web/src/app/admin/admin-content.tsx
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/lib/auth-context";
import { authHeaders } from "@/lib/auth-client";
+import MarketplacePanel from "./marketplace-panel";
type Kind = "outrank" | "crawlproof";
@@ -168,7 +169,9 @@ export default function AdminContent() {
Admin
-
Blog publishing webhooks (Crawlproof, Outrank)
+
+ Blog publishing webhooks (Crawlproof, Outrank) and the GitHub Marketplace listing
+
{error && (
@@ -315,6 +318,8 @@ export default function AdminContent() {
)}
+
+
);
diff --git a/apps/web/src/app/admin/marketplace-panel.tsx b/apps/web/src/app/admin/marketplace-panel.tsx
new file mode 100644
index 0000000..edcfe67
--- /dev/null
+++ b/apps/web/src/app/admin/marketplace-panel.tsx
@@ -0,0 +1,270 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { authHeaders } from "@/lib/auth-client";
+
+const WEBHOOK_PATH = "/api/webhooks/github/marketplace";
+
+const DOCS_HREF =
+ "https://docs.github.com/en/apps/github-marketplace/listing-an-app-on-github-marketplace/configuring-a-webhook-to-notify-you-of-plan-changes";
+
+type Subscription = {
+ id: string;
+ github_account_id: number;
+ github_account_login: string;
+ github_account_type: string | null;
+ plan_name: string | null;
+ plan_monthly_price_cents: number | null;
+ billing_cycle: string | null;
+ unit_count: number | null;
+ on_free_trial: boolean;
+ free_trial_ends_on: string | null;
+ next_billing_date: string | null;
+ status: string;
+ pending_plan_name: string | null;
+ pending_effective_date: string | null;
+ last_action: string | null;
+ updated_at: string;
+};
+
+type Delivery = {
+ id: string;
+ delivery_id: string | null;
+ action: string;
+ github_account_login: string | null;
+ applied: boolean;
+ skip_reason: string | null;
+ received_at: string;
+};
+
+type MarketplaceData = {
+ configured: boolean;
+ migrationApplied: boolean;
+ subscriptions: Subscription[];
+ events: Delivery[];
+};
+
+function fmtDate(value: string | null): string {
+ if (!value) return "—";
+ const ms = Date.parse(value);
+ if (Number.isNaN(ms)) return "—";
+ return new Date(ms).toISOString().slice(0, 10);
+}
+
+function fmtPrice(cents: number | null): string {
+ if (typeof cents !== "number") return "—";
+ return cents === 0 ? "Free" : `$${(cents / 100).toFixed(2)}`;
+}
+
+export default function MarketplacePanel() {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ const webhookUrl =
+ typeof window === "undefined" ? WEBHOOK_PATH : `${window.location.origin}${WEBHOOK_PATH}`;
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await fetch("/api/admin/marketplace", { headers: authHeaders() });
+ if (!res.ok) {
+ setError(res.status === 403 ? "Forbidden" : "Failed to load Marketplace data");
+ return;
+ }
+ setData((await res.json()) as MarketplaceData);
+ } catch {
+ setError("Network error");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ const copy = () => {
+ navigator.clipboard?.writeText(webhookUrl);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ };
+
+ const subs = data?.subscriptions ?? [];
+ const events = data?.events ?? [];
+ const active = subs.filter((s) => s.status === "active").length;
+
+ return (
+
+
+
+ GitHub Marketplace
+
+ docs ↗
+
+
+
+ {loading ? "Loading..." : "Refresh"}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ Paste this as the Payload URL on the listing's Webhook page. Content type{" "}
+ application/json, and set a Secret.
+
+
+
+
+ {webhookUrl}
+
+
+ {copied ? "Copied" : "Copy"}
+
+
+
+
+
+
+ {data?.configured ? "●" : "○"}
+
+
+ {data?.configured
+ ? "Secret configured (GITHUB_MARKETPLACE_WEBHOOK_SECRET)"
+ : "No secret set. Deliveries are rejected with 503 until GITHUB_MARKETPLACE_WEBHOOK_SECRET is set on the service."}
+
+
+ {data && !data.migrationApplied && (
+
+ ○
+
+ Tables missing. Apply{" "}
+
+ supabase/migrations/20260821170000_github_marketplace.sql
+
+ .
+
+
+ )}
+
+
+
+ Subscriptions{" "}
+
+ ({active} active of {subs.length})
+
+
+
+ {subs.length === 0 ? (
+
+ No purchases recorded yet. GitHub does not resend failed deliveries, so if a customer
+ reports a purchase that is missing here, replay it from the listing's delivery log.
+
+ ) : (
+
+
+
+
+ Account
+ Plan
+ Cycle
+ Units
+ Status
+ Next billing
+
+
+
+ {subs.map((s) => (
+
+
+
+ {s.github_account_login}
+
+
+ {s.github_account_type === "Organization" ? "org" : "user"}
+
+
+
+ {s.plan_name ?? "—"}
+
+ {fmtPrice(s.plan_monthly_price_cents)}
+
+ {s.pending_plan_name && (
+
+ → {s.pending_plan_name} on {fmtDate(s.pending_effective_date)}
+
+ )}
+
+ {s.billing_cycle ?? "—"}
+ {s.unit_count ?? "—"}
+
+
+ {s.status}
+
+ {s.on_free_trial && (
+
+ trial → {fmtDate(s.free_trial_ends_on)}
+
+ )}
+
+ {fmtDate(s.next_billing_date)}
+
+ ))}
+
+
+
+ )}
+
+ Recent deliveries
+ {events.length === 0 ? (
+ Nothing delivered yet.
+ ) : (
+
+ {events.map((e) => (
+
+
+ {e.applied ? "✓" : "·"}
+
+ {e.action}
+ {e.github_account_login ?? "unknown"}
+
+ {new Date(e.received_at).toISOString().replace("T", " ").slice(0, 16)}
+
+ {e.skip_reason && (
+ skipped: {e.skip_reason}
+ )}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/app/api/admin/marketplace/route.ts b/apps/web/src/app/api/admin/marketplace/route.ts
new file mode 100644
index 0000000..46b53ed
--- /dev/null
+++ b/apps/web/src/app/api/admin/marketplace/route.ts
@@ -0,0 +1,65 @@
+import { NextRequest, NextResponse } from "next/server";
+import { requireAdmin } from "@/lib/admin-guard";
+import { getSupabaseAdmin } from "@/lib/supabase";
+
+export const runtime = "nodejs";
+
+/**
+ * Admin view of the GitHub Marketplace listing's webhook.
+ *
+ * Reports whether the secret is configured, the current subscription per
+ * GitHub account, and the most recent deliveries. GitHub does not resend
+ * failed deliveries, so the delivery list is the thing to look at when a
+ * customer says they paid and nothing happened.
+ *
+ * The secret itself is never returned, only whether one is set.
+ */
+export async function GET(req: NextRequest) {
+ const guard = await requireAdmin(req);
+ if (guard instanceof NextResponse) return guard;
+
+ const supabase = getSupabaseAdmin();
+
+ const [subs, events] = await Promise.all([
+ supabase
+ .from("github_marketplace_purchases")
+ .select(
+ "id, github_account_id, github_account_login, github_account_type, plan_id, plan_name, plan_monthly_price_cents, billing_cycle, unit_count, on_free_trial, free_trial_ends_on, next_billing_date, status, pending_plan_name, pending_effective_date, effective_date, last_action, updated_at",
+ )
+ .order("updated_at", { ascending: false })
+ .limit(200),
+ supabase
+ .from("github_marketplace_events")
+ .select(
+ "id, delivery_id, action, github_account_login, effective_date, applied, skip_reason, received_at",
+ )
+ .order("received_at", { ascending: false })
+ .limit(50),
+ ]);
+
+ // A missing table is the expected state until the migration is applied
+ // by hand, so say that plainly instead of returning a bare 500.
+ const missingTable =
+ subs.error?.code === "42P01" || events.error?.code === "42P01";
+ if (missingTable) {
+ return NextResponse.json({
+ configured: Boolean(process.env.GITHUB_MARKETPLACE_WEBHOOK_SECRET),
+ webhookPath: "/api/webhooks/github/marketplace",
+ migrationApplied: false,
+ subscriptions: [],
+ events: [],
+ });
+ }
+
+ if (subs.error || events.error) {
+ return NextResponse.json({ error: "Failed to load marketplace data" }, { status: 500 });
+ }
+
+ return NextResponse.json({
+ configured: Boolean(process.env.GITHUB_MARKETPLACE_WEBHOOK_SECRET),
+ webhookPath: "/api/webhooks/github/marketplace",
+ migrationApplied: true,
+ subscriptions: subs.data ?? [],
+ events: events.data ?? [],
+ });
+}
diff --git a/apps/web/src/app/api/webhooks/github/__tests__/marketplace-route.test.ts b/apps/web/src/app/api/webhooks/github/__tests__/marketplace-route.test.ts
new file mode 100644
index 0000000..abfc6ba
--- /dev/null
+++ b/apps/web/src/app/api/webhooks/github/__tests__/marketplace-route.test.ts
@@ -0,0 +1,215 @@
+import { describe, it, expect, vi, beforeEach, afterAll } from "vitest";
+import { createHmac } from "node:crypto";
+
+const originalEnv = { ...process.env };
+
+const SECRET = "test-marketplace-secret";
+
+/**
+ * In-memory stand-in for the two tables. `state` is reset per test so the
+ * dedupe and out-of-order cases can set up their own starting point.
+ */
+const state = {
+ existingPurchase: null as { id: string; effective_date: string | null } | null,
+ eventInsertReturns: [{ id: "event-1" }] as { id: string }[],
+ upsertedPurchases: [] as Record[],
+ eventUpdates: [] as Record[],
+};
+
+vi.mock("@/lib/supabase", () => ({
+ getSupabaseAdmin: () => ({
+ from: (table: string) => {
+ if (table === "github_marketplace_events") {
+ return {
+ upsert: () => ({
+ select: async () => ({ data: state.eventInsertReturns, error: null }),
+ }),
+ update: (patch: Record) => {
+ state.eventUpdates.push(patch);
+ return { eq: async () => ({ error: null }) };
+ },
+ };
+ }
+ // github_marketplace_purchases
+ return {
+ select: () => ({
+ eq: () => ({
+ maybeSingle: async () => ({ data: state.existingPurchase, error: null }),
+ }),
+ }),
+ upsert: async (row: Record) => {
+ state.upsertedPurchases.push(row);
+ return { error: null };
+ },
+ };
+ },
+ }),
+}));
+
+import { POST, GET } from "@/app/api/webhooks/github/marketplace/route";
+
+const PAYLOAD = {
+ action: "purchased",
+ effective_date: "2026-08-21T00:00:00+00:00",
+ sender: { login: "octocat" },
+ marketplace_purchase: {
+ account: { type: "Organization", id: 18404712, login: "acme-corp" },
+ billing_cycle: "monthly",
+ unit_count: 1,
+ on_free_trial: false,
+ next_billing_date: "2026-09-21T00:00:00+00:00",
+ plan: { id: 435, name: "Pro Plan", monthly_price_in_cents: 999 },
+ },
+};
+
+function makeRequest(
+ body: string,
+ {
+ signature,
+ event = "marketplace_purchase",
+ delivery = "delivery-1",
+ contentType = "application/json",
+ }: {
+ signature?: string | null;
+ event?: string;
+ delivery?: string | null;
+ contentType?: string;
+ } = {},
+) {
+ const headers: Record = { "Content-Type": contentType };
+ const sig =
+ signature === undefined
+ ? `sha256=${createHmac("sha256", SECRET).update(body, "utf8").digest("hex")}`
+ : signature;
+ if (sig) headers["X-Hub-Signature-256"] = sig;
+ if (event) headers["X-GitHub-Event"] = event;
+ if (delivery) headers["X-GitHub-Delivery"] = delivery;
+
+ return new Request("http://localhost/api/webhooks/github/marketplace", {
+ method: "POST",
+ headers,
+ body,
+ }) as unknown as import("next/server").NextRequest;
+}
+
+describe("POST /api/webhooks/github/marketplace", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ state.existingPurchase = null;
+ state.eventInsertReturns = [{ id: "event-1" }];
+ state.upsertedPurchases = [];
+ state.eventUpdates = [];
+ process.env.GITHUB_MARKETPLACE_WEBHOOK_SECRET = SECRET;
+ process.env.NEXT_PUBLIC_SUPABASE_URL = "https://test.supabase.co";
+ process.env.SUPABASE_SERVICE_ROLE_KEY = "test-service-key";
+ });
+
+ afterAll(() => {
+ process.env = originalEnv;
+ });
+
+ it("accepts and applies a correctly signed purchase", async () => {
+ const body = JSON.stringify(PAYLOAD);
+ const res = await POST(makeRequest(body));
+ expect(res.status).toBe(200);
+ expect(await res.json()).toMatchObject({ received: true, applied: true });
+ expect(state.upsertedPurchases).toHaveLength(1);
+ expect(state.upsertedPurchases[0]).toMatchObject({
+ github_account_id: 18404712,
+ plan_name: "Pro Plan",
+ status: "active",
+ });
+ });
+
+ it("rejects an unsigned delivery with 401 and writes nothing", async () => {
+ const res = await POST(makeRequest(JSON.stringify(PAYLOAD), { signature: null }));
+ expect(res.status).toBe(401);
+ expect(state.upsertedPurchases).toHaveLength(0);
+ });
+
+ it("rejects a delivery signed with the wrong secret", async () => {
+ const body = JSON.stringify(PAYLOAD);
+ const wrong = `sha256=${createHmac("sha256", "not-the-secret").update(body).digest("hex")}`;
+ const res = await POST(makeRequest(body, { signature: wrong }));
+ expect(res.status).toBe(401);
+ expect(state.upsertedPurchases).toHaveLength(0);
+ });
+
+ it("rejects a body altered after signing", async () => {
+ const body = JSON.stringify(PAYLOAD);
+ const signature = `sha256=${createHmac("sha256", SECRET).update(body).digest("hex")}`;
+ const tampered = JSON.stringify({ ...PAYLOAD, action: "cancelled" });
+ const res = await POST(makeRequest(tampered, { signature }));
+ expect(res.status).toBe(401);
+ expect(state.upsertedPurchases).toHaveLength(0);
+ });
+
+ it("refuses with 503 when no secret is configured, rather than accepting", async () => {
+ delete process.env.GITHUB_MARKETPLACE_WEBHOOK_SECRET;
+ const res = await POST(makeRequest(JSON.stringify(PAYLOAD)));
+ expect(res.status).toBe(503);
+ expect(state.upsertedPurchases).toHaveLength(0);
+ });
+
+ it("answers the ping GitHub sends when the hook is saved", async () => {
+ const body = JSON.stringify({ zen: "Keep it logically awesome." });
+ const res = await POST(makeRequest(body, { event: "ping" }));
+ expect(res.status).toBe(200);
+ expect(await res.json()).toMatchObject({ pong: true });
+ });
+
+ it("ignores a signed event that is not marketplace_purchase", async () => {
+ const body = JSON.stringify(PAYLOAD);
+ const res = await POST(makeRequest(body, { event: "installation" }));
+ expect(res.status).toBe(200);
+ expect(await res.json()).toMatchObject({ ignored: "installation" });
+ expect(state.upsertedPurchases).toHaveLength(0);
+ });
+
+ it("treats a repeated delivery id as a duplicate and does not re-apply it", async () => {
+ // ignoreDuplicates means the upsert returns no row for a delivery we
+ // have already seen.
+ state.eventInsertReturns = [];
+ const res = await POST(makeRequest(JSON.stringify(PAYLOAD)));
+ expect(res.status).toBe(200);
+ expect(await res.json()).toMatchObject({ duplicate: true });
+ expect(state.upsertedPurchases).toHaveLength(0);
+ });
+
+ it("does not let an older event overwrite newer state", async () => {
+ state.existingPurchase = { id: "p1", effective_date: "2026-09-01T00:00:00.000Z" };
+ const stale = { ...PAYLOAD, effective_date: "2026-08-01T00:00:00+00:00" };
+ const res = await POST(makeRequest(JSON.stringify(stale)));
+ expect(res.status).toBe(200);
+ expect(await res.json()).toMatchObject({ stale: true, applied: false });
+ expect(state.upsertedPurchases).toHaveLength(0);
+ expect(state.eventUpdates[0]).toMatchObject({ applied: false });
+ });
+
+ it("accepts the urlencoded content type as well", async () => {
+ const body = `payload=${encodeURIComponent(JSON.stringify(PAYLOAD))}`;
+ const res = await POST(
+ makeRequest(body, { contentType: "application/x-www-form-urlencoded" }),
+ );
+ expect(res.status).toBe(200);
+ expect(await res.json()).toMatchObject({ applied: true });
+ });
+
+ it("ignores an unknown action without failing the delivery", async () => {
+ const body = JSON.stringify({ ...PAYLOAD, action: "exploded" });
+ const res = await POST(makeRequest(body));
+ expect(res.status).toBe(200);
+ expect(await res.json()).toMatchObject({ ignored: "exploded" });
+ expect(state.upsertedPurchases).toHaveLength(0);
+ });
+});
+
+describe("GET /api/webhooks/github/marketplace", () => {
+ it("reports configuration without leaking the secret", async () => {
+ process.env.GITHUB_MARKETPLACE_WEBHOOK_SECRET = SECRET;
+ const res = await GET();
+ const body = await res.json();
+ expect(body.configured).toBe(true);
+ expect(JSON.stringify(body)).not.toContain(SECRET);
+ });
+});
diff --git a/apps/web/src/app/api/webhooks/github/marketplace/route.ts b/apps/web/src/app/api/webhooks/github/marketplace/route.ts
new file mode 100644
index 0000000..9a935d5
--- /dev/null
+++ b/apps/web/src/app/api/webhooks/github/marketplace/route.ts
@@ -0,0 +1,191 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getSupabaseAdmin } from "@/lib/supabase";
+import {
+ buildPurchaseRow,
+ isMarketplaceAction,
+ isStaleEvent,
+ parseWebhookBody,
+ verifyGithubSignature,
+} from "@/lib/github-marketplace";
+
+/**
+ * GitHub Marketplace plan-change webhook.
+ *
+ * Configured on the Marketplace listing page (not in the App's developer
+ * settings, which is where every other event is configured):
+ *
+ * Payload URL https://threatcrush.com/api/webhooks/github/marketplace
+ * Content type application/json
+ * Secret GITHUB_MARKETPLACE_WEBHOOK_SECRET
+ *
+ * GitHub does NOT resend failed deliveries. A 500 from here loses the
+ * event permanently, so the raw payload is written to
+ * github_marketplace_events before anything else can fail, and the
+ * listing's delivery log is the only other copy.
+ */
+
+// node:crypto and the raw-body read both need the Node runtime.
+export const runtime = "nodejs";
+
+/**
+ * Webhook fields land in log lines, and a value containing CRLF can forge
+ * a whole extra entry. Strip the control characters and cap the length so
+ * a field can only ever be one token.
+ */
+function logSafe(value: unknown): string {
+ const collapsed = String(value ?? "")
+ // eslint-disable-next-line no-control-regex
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
+ .slice(0, 200);
+ return collapsed.replace(/\n|\r/g, " ");
+}
+
+export async function POST(req: NextRequest) {
+ // Must be the raw bytes: the signature is over the body as sent.
+ const rawBody = await req.text();
+
+ const secret = process.env.GITHUB_MARKETPLACE_WEBHOOK_SECRET;
+ if (!secret) {
+ // Refuse rather than accept-and-ignore. An unconfigured deployment
+ // that returns 200 looks healthy in the delivery log while silently
+ // dropping every purchase.
+ console.error("[gh marketplace] GITHUB_MARKETPLACE_WEBHOOK_SECRET is not set");
+ return NextResponse.json({ error: "Webhook not configured" }, { status: 503 });
+ }
+
+ const signature = req.headers.get("x-hub-signature-256");
+ if (!verifyGithubSignature(rawBody, signature, secret)) {
+ console.warn("[gh marketplace] rejected delivery with invalid signature");
+ return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
+ }
+
+ const event = req.headers.get("x-github-event") ?? "";
+ const deliveryId = req.headers.get("x-github-delivery");
+
+ // The listing only sends marketplace_purchase, but `ping` arrives when
+ // the hook is first saved and must be answered 200 or the UI shows the
+ // hook as broken.
+ if (event === "ping") {
+ return NextResponse.json({ received: true, pong: true });
+ }
+ if (event && event !== "marketplace_purchase") {
+ return NextResponse.json({ received: true, ignored: event });
+ }
+
+ const payload = parseWebhookBody(rawBody, req.headers.get("content-type"));
+ if (!payload) {
+ return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+ }
+
+ const action = payload.action;
+ if (!isMarketplaceAction(action)) {
+ console.warn(`[gh marketplace] unknown action: ${logSafe(action)}`);
+ return NextResponse.json({ received: true, ignored: String(action ?? "") });
+ }
+
+ const now = new Date().toISOString();
+ const row = buildPurchaseRow(action, payload, now);
+
+ let supabase;
+ try {
+ supabase = getSupabaseAdmin();
+ } catch (e) {
+ console.error("[gh marketplace] supabase unavailable:", e);
+ // 500 so the delivery shows red in GitHub's log and can be replayed
+ // by hand from the payload GitHub still holds.
+ return NextResponse.json({ error: "Storage unavailable" }, { status: 500 });
+ }
+
+ // Record the delivery first. `delivery_id` is unique, so a replay of the
+ // same delivery inserts nothing and we can stop early.
+ const eventRow = {
+ delivery_id: deliveryId,
+ action,
+ github_account_id: row?.github_account_id ?? null,
+ github_account_login: row?.github_account_login ?? null,
+ effective_date: row?.effective_date ?? null,
+ applied: false,
+ skip_reason: null as string | null,
+ payload: payload as unknown as Record,
+ received_at: now,
+ };
+
+ const { data: insertedEvents, error: eventErr } = await supabase
+ .from("github_marketplace_events")
+ .upsert(eventRow, { onConflict: "delivery_id", ignoreDuplicates: true })
+ .select("id");
+
+ if (eventErr) {
+ console.error("[gh marketplace] failed to log delivery:", eventErr);
+ return NextResponse.json({ error: "Storage failed" }, { status: 500 });
+ }
+
+ const eventId = insertedEvents?.[0]?.id as string | undefined;
+ if (deliveryId && !eventId) {
+ // Already processed this exact delivery.
+ return NextResponse.json({ received: true, duplicate: true });
+ }
+
+ const finish = async (applied: boolean, skipReason?: string) => {
+ if (!eventId) return;
+ await supabase
+ .from("github_marketplace_events")
+ .update({ applied, skip_reason: skipReason ?? null })
+ .eq("id", eventId);
+ };
+
+ if (!row) {
+ await finish(false, "no account id in payload");
+ return NextResponse.json({ received: true, applied: false });
+ }
+
+ const { data: existing, error: readErr } = await supabase
+ .from("github_marketplace_purchases")
+ .select("id, effective_date")
+ .eq("github_account_id", row.github_account_id)
+ .maybeSingle();
+
+ if (readErr) {
+ console.error("[gh marketplace] failed to read purchase:", readErr);
+ return NextResponse.json({ error: "Storage failed" }, { status: 500 });
+ }
+
+ if (existing && isStaleEvent(existing.effective_date as string | null, row.effective_date)) {
+ console.warn(
+ `[gh marketplace] ignoring out-of-order ${logSafe(action)} for ${logSafe(row.github_account_login)}`,
+ );
+ await finish(false, "older than the applied event");
+ return NextResponse.json({ received: true, applied: false, stale: true });
+ }
+
+ const { error: upsertErr } = await supabase
+ .from("github_marketplace_purchases")
+ .upsert(row, { onConflict: "github_account_id" });
+
+ if (upsertErr) {
+ console.error("[gh marketplace] failed to upsert purchase:", upsertErr);
+ return NextResponse.json({ error: "Storage failed" }, { status: 500 });
+ }
+
+ await finish(true);
+
+ console.log(
+ `[gh marketplace] ${logSafe(action)} ${logSafe(row.github_account_login)} -> ${logSafe(row.plan_name)}`,
+ );
+
+ return NextResponse.json({ received: true, applied: true, action });
+}
+
+/**
+ * A GET here is almost always a human checking the URL is live before
+ * pasting it into the listing. Answer usefully and never leak the secret.
+ */
+export async function GET() {
+ return NextResponse.json({
+ endpoint: "github-marketplace-webhook",
+ method: "POST",
+ event: "marketplace_purchase",
+ contentType: "application/json",
+ configured: Boolean(process.env.GITHUB_MARKETPLACE_WEBHOOK_SECRET),
+ });
+}
diff --git a/apps/web/src/lib/__tests__/github-marketplace.test.ts b/apps/web/src/lib/__tests__/github-marketplace.test.ts
new file mode 100644
index 0000000..674fe88
--- /dev/null
+++ b/apps/web/src/lib/__tests__/github-marketplace.test.ts
@@ -0,0 +1,224 @@
+import { describe, expect, it } from "vitest";
+import { createHmac } from "node:crypto";
+import {
+ buildPurchaseRow,
+ currentPurchaseFor,
+ isMarketplaceAction,
+ isStaleEvent,
+ parseWebhookBody,
+ verifyGithubSignature,
+ type MarketplaceEventPayload,
+} from "../github-marketplace";
+
+const SECRET = "s3cret-webhook-token";
+
+function sign(body: string, secret = SECRET): string {
+ return `sha256=${createHmac("sha256", secret).update(body, "utf8").digest("hex")}`;
+}
+
+// Shaped after the example payload in GitHub's docs.
+const PURCHASED: MarketplaceEventPayload = {
+ action: "purchased",
+ effective_date: "2026-08-21T00:00:00+00:00",
+ sender: { login: "octocat" },
+ marketplace_purchase: {
+ account: {
+ type: "Organization",
+ id: 18404712,
+ node_id: "MDEyOk9yZ2FuaXphdGlvbjE4NDA0NzEy",
+ login: "acme-corp",
+ organization_billing_email: "billing@acme.test",
+ },
+ billing_cycle: "monthly",
+ unit_count: 3,
+ on_free_trial: false,
+ free_trial_ends_on: null,
+ next_billing_date: "2026-09-21T00:00:00+00:00",
+ plan: {
+ id: 435,
+ name: "Pro Plan",
+ monthly_price_in_cents: 999,
+ yearly_price_in_cents: 9999,
+ },
+ },
+};
+
+describe("verifyGithubSignature", () => {
+ it("accepts a correctly signed body", () => {
+ const body = JSON.stringify(PURCHASED);
+ expect(verifyGithubSignature(body, sign(body), SECRET)).toBe(true);
+ });
+
+ it("rejects a body signed with a different secret", () => {
+ const body = JSON.stringify(PURCHASED);
+ expect(verifyGithubSignature(body, sign(body, "wrong"), SECRET)).toBe(false);
+ });
+
+ it("rejects a tampered body", () => {
+ const body = JSON.stringify(PURCHASED);
+ const signature = sign(body);
+ expect(verifyGithubSignature(body + " ", signature, SECRET)).toBe(false);
+ });
+
+ it("rejects when the secret is missing, rather than passing", () => {
+ const body = JSON.stringify(PURCHASED);
+ expect(verifyGithubSignature(body, sign(body), undefined)).toBe(false);
+ expect(verifyGithubSignature(body, sign(body), "")).toBe(false);
+ });
+
+ it("rejects a missing or malformed signature header without throwing", () => {
+ const body = JSON.stringify(PURCHASED);
+ expect(verifyGithubSignature(body, null, SECRET)).toBe(false);
+ expect(verifyGithubSignature(body, "sha256=short", SECRET)).toBe(false);
+ expect(verifyGithubSignature(body, "garbage", SECRET)).toBe(false);
+ });
+});
+
+describe("parseWebhookBody", () => {
+ it("parses application/json", () => {
+ const body = JSON.stringify(PURCHASED);
+ expect(parseWebhookBody(body, "application/json")?.action).toBe("purchased");
+ });
+
+ it("parses the urlencoded form, where the JSON arrives in a payload field", () => {
+ const body = `payload=${encodeURIComponent(JSON.stringify(PURCHASED))}`;
+ const parsed = parseWebhookBody(body, "application/x-www-form-urlencoded");
+ expect(parsed?.action).toBe("purchased");
+ expect(parsed?.marketplace_purchase?.account?.login).toBe("acme-corp");
+ });
+
+ it("returns null on malformed JSON instead of throwing", () => {
+ expect(parseWebhookBody("{not json", "application/json")).toBeNull();
+ expect(parseWebhookBody("", "application/json")).toBeNull();
+ });
+});
+
+describe("isMarketplaceAction", () => {
+ it("accepts the five documented actions and nothing else", () => {
+ for (const a of [
+ "purchased",
+ "changed",
+ "cancelled",
+ "pending_change",
+ "pending_change_cancelled",
+ ]) {
+ expect(isMarketplaceAction(a)).toBe(true);
+ }
+ expect(isMarketplaceAction("deleted")).toBe(false);
+ expect(isMarketplaceAction(undefined)).toBe(false);
+ });
+});
+
+describe("buildPurchaseRow", () => {
+ const now = "2026-08-21T12:00:00.000Z";
+
+ it("maps a purchase onto the stored row", () => {
+ const row = buildPurchaseRow("purchased", PURCHASED, now);
+ expect(row).not.toBeNull();
+ expect(row!.github_account_id).toBe(18404712);
+ expect(row!.github_account_login).toBe("acme-corp");
+ expect(row!.github_account_type).toBe("Organization");
+ expect(row!.plan_id).toBe(435);
+ expect(row!.plan_name).toBe("Pro Plan");
+ expect(row!.plan_monthly_price_cents).toBe(999);
+ expect(row!.billing_cycle).toBe("monthly");
+ expect(row!.unit_count).toBe(3);
+ expect(row!.status).toBe("active");
+ expect(row!.sender_login).toBe("octocat");
+ expect(row!.effective_date).toBe("2026-08-21T00:00:00.000Z");
+ });
+
+ it("returns null when the payload carries no account id", () => {
+ expect(buildPurchaseRow("purchased", { action: "purchased" }, now)).toBeNull();
+ });
+
+ it("marks a cancellation cancelled", () => {
+ const row = buildPurchaseRow("cancelled", { ...PURCHASED, action: "cancelled" }, now);
+ expect(row!.status).toBe("cancelled");
+ });
+
+ it("keeps the CURRENT plan on a pending downgrade and records the future one", () => {
+ // The regression this guards: reading marketplace_purchase on a
+ // pending_change downgrades the customer immediately instead of at the
+ // end of their billing cycle.
+ const pending: MarketplaceEventPayload = {
+ action: "pending_change",
+ effective_date: "2026-09-21T00:00:00+00:00",
+ sender: { login: "octocat" },
+ marketplace_purchase: {
+ account: PURCHASED.marketplace_purchase!.account,
+ billing_cycle: "monthly",
+ unit_count: 1,
+ plan: { id: 1, name: "Free Plan", monthly_price_in_cents: 0 },
+ },
+ previous_marketplace_purchase: PURCHASED.marketplace_purchase,
+ };
+
+ const row = buildPurchaseRow("pending_change", pending, now);
+ expect(row!.plan_name).toBe("Pro Plan");
+ expect(row!.plan_id).toBe(435);
+ expect(row!.status).toBe("active");
+ expect(row!.pending_plan_name).toBe("Free Plan");
+ expect(row!.pending_plan_id).toBe(1);
+ expect(row!.pending_effective_date).toBe("2026-09-21T00:00:00.000Z");
+ });
+
+ it("clears a pending change on any non-pending action", () => {
+ const row = buildPurchaseRow("changed", { ...PURCHASED, action: "changed" }, now);
+ expect(row!.pending_plan_id).toBeNull();
+ expect(row!.pending_plan_name).toBeNull();
+ expect(row!.pending_effective_date).toBeNull();
+ });
+
+ it("treats a free trial as active and keeps the trial end date", () => {
+ const trial: MarketplaceEventPayload = {
+ ...PURCHASED,
+ marketplace_purchase: {
+ ...PURCHASED.marketplace_purchase!,
+ on_free_trial: true,
+ free_trial_ends_on: "2026-09-04T00:00:00+00:00",
+ },
+ };
+ const row = buildPurchaseRow("purchased", trial, now);
+ expect(row!.on_free_trial).toBe(true);
+ expect(row!.free_trial_ends_on).toBe("2026-09-04T00:00:00.000Z");
+ expect(row!.status).toBe("active");
+ });
+
+ it("survives a payload with no plan or dates", () => {
+ const bare: MarketplaceEventPayload = {
+ action: "purchased",
+ marketplace_purchase: { account: { id: 7, login: "solo" } },
+ };
+ const row = buildPurchaseRow("purchased", bare, now);
+ expect(row!.plan_id).toBeNull();
+ expect(row!.effective_date).toBeNull();
+ expect(row!.next_billing_date).toBeNull();
+ expect(row!.on_free_trial).toBe(false);
+ });
+});
+
+describe("currentPurchaseFor", () => {
+ it("falls back to marketplace_purchase when there is no previous one", () => {
+ const p = currentPurchaseFor("pending_change", PURCHASED);
+ expect(p.plan?.name).toBe("Pro Plan");
+ });
+});
+
+describe("isStaleEvent", () => {
+ it("rejects an event older than what was applied", () => {
+ expect(isStaleEvent("2026-08-21T00:00:00Z", "2026-08-20T00:00:00Z")).toBe(true);
+ });
+
+ it("allows a newer or equal event", () => {
+ expect(isStaleEvent("2026-08-21T00:00:00Z", "2026-08-22T00:00:00Z")).toBe(false);
+ // purchased and changed can share a timestamp; the later arrival wins.
+ expect(isStaleEvent("2026-08-21T00:00:00Z", "2026-08-21T00:00:00Z")).toBe(false);
+ });
+
+ it("does not block when either side is missing or unparseable", () => {
+ expect(isStaleEvent(null, "2026-08-21T00:00:00Z")).toBe(false);
+ expect(isStaleEvent("2026-08-21T00:00:00Z", null)).toBe(false);
+ expect(isStaleEvent("nonsense", "2026-08-21T00:00:00Z")).toBe(false);
+ });
+});
diff --git a/apps/web/src/lib/github-marketplace.ts b/apps/web/src/lib/github-marketplace.ts
new file mode 100644
index 0000000..9cdc269
--- /dev/null
+++ b/apps/web/src/lib/github-marketplace.ts
@@ -0,0 +1,231 @@
+import { createHmac, timingSafeEqual } from "node:crypto";
+
+/**
+ * GitHub Marketplace `marketplace_purchase` webhook.
+ *
+ * Docs: https://docs.github.com/en/apps/github-marketplace/listing-an-app-on-github-marketplace/configuring-a-webhook-to-notify-you-of-plan-changes
+ *
+ * Everything here is pure so it can be tested without a request or a
+ * database. The route is a thin shell over these functions.
+ */
+
+export const MARKETPLACE_ACTIONS = [
+ "purchased",
+ "changed",
+ "cancelled",
+ "pending_change",
+ "pending_change_cancelled",
+] as const;
+
+export type MarketplaceAction = (typeof MARKETPLACE_ACTIONS)[number];
+
+export function isMarketplaceAction(value: unknown): value is MarketplaceAction {
+ return (MARKETPLACE_ACTIONS as readonly string[]).includes(String(value));
+}
+
+export type MarketplacePlan = {
+ id?: number;
+ name?: string;
+ monthly_price_in_cents?: number;
+ yearly_price_in_cents?: number;
+};
+
+export type MarketplaceAccount = {
+ id?: number;
+ login?: string;
+ type?: string;
+ node_id?: string;
+ organization_billing_email?: string | null;
+};
+
+export type MarketplacePurchase = {
+ account?: MarketplaceAccount;
+ billing_cycle?: string | null;
+ unit_count?: number | null;
+ on_free_trial?: boolean | null;
+ free_trial_ends_on?: string | null;
+ next_billing_date?: string | null;
+ plan?: MarketplacePlan;
+};
+
+export type MarketplaceEventPayload = {
+ action?: string;
+ effective_date?: string;
+ sender?: { login?: string };
+ marketplace_purchase?: MarketplacePurchase;
+ previous_marketplace_purchase?: MarketplacePurchase;
+};
+
+/**
+ * Verify X-Hub-Signature-256.
+ *
+ * The header is `sha256=` over the RAW request body, so the body must
+ * be read as text before any JSON parsing. Re-serialising a parsed object
+ * changes the bytes and the signature will never match.
+ *
+ * Returns false rather than throwing on every failure mode, including a
+ * missing secret: an unconfigured deployment must reject deliveries, not
+ * accept them.
+ */
+export function verifyGithubSignature(
+ rawBody: string,
+ signatureHeader: string | null | undefined,
+ secret: string | null | undefined,
+): boolean {
+ if (!rawBody || !signatureHeader || !secret) return false;
+
+ const expected = `sha256=${createHmac("sha256", secret).update(rawBody, "utf8").digest("hex")}`;
+
+ const a = Buffer.from(signatureHeader);
+ const b = Buffer.from(expected);
+ // timingSafeEqual throws on a length mismatch, which would itself leak
+ // the expected length through an exception path. Check first.
+ if (a.length !== b.length) return false;
+ return timingSafeEqual(a, b);
+}
+
+/**
+ * Parse the body for either content type.
+ *
+ * JSON is what we configure and what GitHub recommends. The urlencoded
+ * form sends the same JSON as a single `payload` field, and is accepted
+ * here so that a mis-set content type in the listing UI degrades to a
+ * working webhook instead of a silent parse failure.
+ */
+export function parseWebhookBody(
+ rawBody: string,
+ contentType: string | null | undefined,
+): MarketplaceEventPayload | null {
+ const type = (contentType ?? "").toLowerCase();
+ try {
+ if (type.includes("application/x-www-form-urlencoded")) {
+ const encoded = new URLSearchParams(rawBody).get("payload");
+ if (!encoded) return null;
+ return JSON.parse(encoded) as MarketplaceEventPayload;
+ }
+ return JSON.parse(rawBody) as MarketplaceEventPayload;
+ } catch {
+ return null;
+ }
+}
+
+function toIso(value: unknown): string | null {
+ if (typeof value !== "string" || !value) return null;
+ const ms = Date.parse(value);
+ return Number.isNaN(ms) ? null : new Date(ms).toISOString();
+}
+
+export type PurchaseRow = {
+ github_account_id: number;
+ github_account_login: string;
+ github_account_type: string | null;
+ github_account_node_id: string | null;
+ organization_billing_email: string | null;
+ plan_id: number | null;
+ plan_name: string | null;
+ plan_monthly_price_cents: number | null;
+ plan_yearly_price_cents: number | null;
+ billing_cycle: string | null;
+ unit_count: number | null;
+ on_free_trial: boolean;
+ free_trial_ends_on: string | null;
+ next_billing_date: string | null;
+ status: string;
+ pending_plan_id: number | null;
+ pending_plan_name: string | null;
+ pending_effective_date: string | null;
+ sender_login: string | null;
+ effective_date: string | null;
+ last_action: string;
+ updated_at: string;
+};
+
+/**
+ * Which purchase object describes the plan the customer is on RIGHT NOW.
+ *
+ * For `pending_change` the top-level marketplace_purchase is the plan that
+ * will take effect later, so the current plan is the previous one. Reading
+ * the wrong one here downgrades a customer the moment they schedule a
+ * downgrade, rather than at the end of their billing cycle.
+ */
+export function currentPurchaseFor(
+ action: MarketplaceAction,
+ payload: MarketplaceEventPayload,
+): MarketplacePurchase {
+ if (action === "pending_change") {
+ return payload.previous_marketplace_purchase ?? payload.marketplace_purchase ?? {};
+ }
+ return payload.marketplace_purchase ?? {};
+}
+
+/**
+ * Map a validated event onto the row we store. Returns null when the
+ * payload has no usable account id, which is the only field we cannot
+ * work without.
+ */
+export function buildPurchaseRow(
+ action: MarketplaceAction,
+ payload: MarketplaceEventPayload,
+ now: string,
+): PurchaseRow | null {
+ const current = currentPurchaseFor(action, payload);
+ const account = current.account ?? payload.marketplace_purchase?.account ?? {};
+ const accountId = typeof account.id === "number" ? account.id : null;
+ if (accountId === null) return null;
+
+ const plan = current.plan ?? {};
+ const effective = toIso(payload.effective_date);
+
+ // pending_change carries the future plan at the top level; every other
+ // action either has no pending change or resolves one.
+ const incoming = payload.marketplace_purchase ?? {};
+ const isPending = action === "pending_change";
+
+ return {
+ github_account_id: accountId,
+ github_account_login: account.login ?? String(accountId),
+ github_account_type: account.type ?? null,
+ github_account_node_id: account.node_id ?? null,
+ organization_billing_email: account.organization_billing_email ?? null,
+ plan_id: typeof plan.id === "number" ? plan.id : null,
+ plan_name: plan.name ?? null,
+ plan_monthly_price_cents:
+ typeof plan.monthly_price_in_cents === "number" ? plan.monthly_price_in_cents : null,
+ plan_yearly_price_cents:
+ typeof plan.yearly_price_in_cents === "number" ? plan.yearly_price_in_cents : null,
+ billing_cycle: current.billing_cycle ?? null,
+ unit_count: typeof current.unit_count === "number" ? current.unit_count : null,
+ on_free_trial: current.on_free_trial === true,
+ free_trial_ends_on: toIso(current.free_trial_ends_on),
+ next_billing_date: toIso(current.next_billing_date),
+ // A pending downgrade does not end the subscription, so only an actual
+ // `cancelled` event moves the row out of active.
+ status: action === "cancelled" ? "cancelled" : "active",
+ pending_plan_id: isPending && typeof incoming.plan?.id === "number" ? incoming.plan.id : null,
+ pending_plan_name: isPending ? (incoming.plan?.name ?? null) : null,
+ pending_effective_date: isPending ? effective : null,
+ sender_login: payload.sender?.login ?? null,
+ effective_date: effective,
+ last_action: action,
+ updated_at: now,
+ };
+}
+
+/**
+ * GitHub does not resend failed deliveries, but it does not guarantee
+ * order either, and a retry driven by us can arrive late. An event whose
+ * effective_date predates what we already applied must not overwrite it.
+ *
+ * Equal timestamps are allowed through: `purchased` and `changed` can
+ * share a second, and the later-arriving one is the one we want.
+ */
+export function isStaleEvent(
+ storedEffectiveDate: string | null | undefined,
+ incomingEffectiveDate: string | null | undefined,
+): boolean {
+ if (!storedEffectiveDate || !incomingEffectiveDate) return false;
+ const stored = Date.parse(storedEffectiveDate);
+ const incoming = Date.parse(incomingEffectiveDate);
+ if (Number.isNaN(stored) || Number.isNaN(incoming)) return false;
+ return incoming < stored;
+}
diff --git a/supabase/migrations/20260821170000_github_marketplace.sql b/supabase/migrations/20260821170000_github_marketplace.sql
new file mode 100644
index 0000000..838d46e
--- /dev/null
+++ b/supabase/migrations/20260821170000_github_marketplace.sql
@@ -0,0 +1,100 @@
+-- GitHub Marketplace plan-change webhook.
+--
+-- The Marketplace listing posts `marketplace_purchase` events to
+-- /api/webhooks/github/marketplace whenever a customer buys, upgrades,
+-- downgrades or cancels a plan. See
+-- https://docs.github.com/en/apps/github-marketplace/listing-an-app-on-github-marketplace/configuring-a-webhook-to-notify-you-of-plan-changes
+--
+-- Two tables, on purpose:
+--
+-- github_marketplace_purchases the CURRENT subscription per GitHub
+-- account. One row per account, upserted.
+-- github_marketplace_events every delivery, raw. GitHub does not
+-- resend failed deliveries, so the raw log
+-- is the only way to replay a bad deploy.
+--
+-- Both are written exclusively by the service role from the webhook route.
+-- RLS is enabled with no policies, so anon and authenticated cannot read a
+-- customer's billing state even if a query slips into client code.
+
+create table if not exists public.github_marketplace_purchases (
+ id uuid primary key default gen_random_uuid(),
+
+ -- account.id is GitHub's stable numeric id. The login can be renamed,
+ -- so the id is the key and the login is a display convenience.
+ github_account_id bigint not null unique,
+ github_account_login text not null,
+ github_account_type text,
+ github_account_node_id text,
+ organization_billing_email text,
+
+ plan_id bigint,
+ plan_name text,
+ plan_monthly_price_cents integer,
+ plan_yearly_price_cents integer,
+ billing_cycle text,
+ unit_count integer,
+ on_free_trial boolean not null default false,
+ free_trial_ends_on timestamptz,
+ next_billing_date timestamptz,
+
+ -- active | cancelled | pending_change
+ status text not null default 'active',
+
+ -- Set by pending_change, cleared by pending_change_cancelled or by the
+ -- `changed` event that actually applies it.
+ pending_plan_id bigint,
+ pending_plan_name text,
+ pending_effective_date timestamptz,
+
+ sender_login text,
+
+ -- The effective_date of the most recent event applied to this row.
+ -- Guards against out-of-order delivery: an older event must not
+ -- overwrite a newer state.
+ effective_date timestamptz,
+ last_action text,
+
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+create index if not exists idx_gh_marketplace_purchases_login
+ on public.github_marketplace_purchases (github_account_login);
+
+create index if not exists idx_gh_marketplace_purchases_status
+ on public.github_marketplace_purchases (status);
+
+create table if not exists public.github_marketplace_events (
+ id uuid primary key default gen_random_uuid(),
+
+ -- X-GitHub-Delivery. Unique so a retry of the same delivery is a no-op
+ -- rather than a duplicate row.
+ delivery_id text unique,
+
+ action text not null,
+ github_account_id bigint,
+ github_account_login text,
+ effective_date timestamptz,
+
+ -- Whether this delivery changed the purchases row, and why not if it did
+ -- not. Makes "the webhook fired but nothing happened" answerable.
+ applied boolean not null default false,
+ skip_reason text,
+
+ payload jsonb not null,
+ received_at timestamptz not null default now()
+);
+
+create index if not exists idx_gh_marketplace_events_received
+ on public.github_marketplace_events (received_at desc);
+
+create index if not exists idx_gh_marketplace_events_account
+ on public.github_marketplace_events (github_account_id);
+
+alter table public.github_marketplace_purchases enable row level security;
+alter table public.github_marketplace_events enable row level security;
+
+-- No policies: service role only. Deliberate.
+revoke all on public.github_marketplace_purchases from anon, authenticated;
+revoke all on public.github_marketplace_events from anon, authenticated;