Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions apps/web/components/dashboard/quota-banner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QuotaRejectionDetail } from "./quota-banner";

/** The text a reader sees: everything outside `<...>` tags in rendered markup. */
function visibleText(html: string): string {
let text = "";
let inTag = false;
for (const char of html) {
if (char === "<") inTag = true;
else if (char === ">") inTag = false;
else if (!inTag) text += char;
}
return text;
}

describe("QuotaRejectionDetail", () => {
it("renders nothing before any webhook was rejected", () => {
expect(renderToStaticMarkup(<QuotaRejectionDetail rejections={null} />)).toBe("");
expect(
renderToStaticMarkup(
<QuotaRejectionDetail rejections={{ total: 0, endpointCount: 0, endpoints: [] }} />
)
).toBe("");
});

it("names the total and each affected endpoint", () => {
const html = renderToStaticMarkup(
<QuotaRejectionDetail
rejections={{
total: 1420,
endpointCount: 2,
endpoints: [
{ slug: "stripe-prod", rejected: 1000 },
{ slug: "gh-staging", rejected: 420 },
],
}}
/>
);
const text = visibleText(html);
expect(text).toBe(
"1,420 webhooks rejected this period on stripe-prod (1,000), gh-staging (420)."
);
expect(html).toContain('<code class="font-mono">stripe-prod</code>');
});

it("uses singular wording and summarizes unlisted endpoints", () => {
const one = renderToStaticMarkup(
<QuotaRejectionDetail
rejections={{ total: 1, endpointCount: 1, endpoints: [{ slug: "solo", rejected: 1 }] }}
/>
);
expect(visibleText(one)).toBe("1 webhook rejected this period on solo (1).");

const many = renderToStaticMarkup(
<QuotaRejectionDetail
rejections={{ total: 9, endpointCount: 4, endpoints: [{ slug: "a", rejected: 9 }] }}
/>
);
expect(visibleText(many)).toBe(
"9 webhooks rejected this period on a (9) and 3 more endpoints."
);
});

it("escapes slugs", () => {
const html = renderToStaticMarkup(
<QuotaRejectionDetail
rejections={{
total: 1,
endpointCount: 1,
endpoints: [{ slug: "<script>x</script>", rejected: 1 }],
}}
/>
);
expect(html).not.toContain("<script>");
});
});
45 changes: 41 additions & 4 deletions apps/web/components/dashboard/quota-banner.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,47 @@
"use client";

import { useEffect, useState } from "react";
import { Fragment, useEffect, useState } from "react";
import Link from "next/link";
import { OctagonAlert } from "lucide-react";
import { useAuth } from "@/components/providers/supabase-auth-provider";
import { UpgradeButton } from "@/components/billing/upgrade-button";
import { ACCOUNT_PROFILE_SELECT, type AccountProfile } from "@/lib/account-profile";
import { isQuotaExhausted } from "@/lib/quota";
import { createClient } from "@/lib/supabase/client";
import type { QuotaRejections } from "@/lib/supabase/quota-rejections";
import { subscribeToUserProfileChanges } from "@/lib/supabase/realtime";

function plural(count: number, one: string, many: string): string {
return `${count.toLocaleString("en-US")} ${count === 1 ? one : many}`;
}

/**
* What the receiver has turned away this period, per endpoint. Renders nothing
* until a webhook has actually been rejected: the banner appears the moment
* the quota runs out, before the first 429.
*/
export function QuotaRejectionDetail({ rejections }: { rejections: QuotaRejections | null }) {
if (!rejections || rejections.total <= 0 || rejections.endpoints.length === 0) return null;

const more = Math.max(0, rejections.endpointCount - rejections.endpoints.length);
return (
<p className="text-sm">
<span className="font-medium">
{plural(rejections.total, "webhook", "webhooks")} rejected this period
</span>{" "}
on{" "}
{rejections.endpoints.map((endpoint, index) => (
<Fragment key={endpoint.slug}>
{index > 0 && ", "}
<code className="font-mono">{endpoint.slug}</code> (
{endpoint.rejected.toLocaleString("en-US")})
</Fragment>
))}
{more > 0 && ` and ${plural(more, "more endpoint", "more endpoints")}`}.
</p>
);
}

/**
* Shown while the receiver is rejecting the user's webhooks with 429. Without
* it a capped user only sees captures stop arriving; usage lives on /account.
Expand All @@ -21,6 +53,7 @@ export function QuotaBanner() {
const [profile, setProfile] = useState<AccountProfile | null>(null);
const [nowMs, setNowMs] = useState(() => Date.now());
const [teamBilledEndpoints, setTeamBilledEndpoints] = useState(0);
const [rejections, setRejections] = useState<QuotaRejections | null>(null);
const userId = user?.id;
const accessToken = session?.access_token ?? null;

Expand Down Expand Up @@ -60,14 +93,17 @@ export function QuotaBanner() {
const exhausted = isQuotaExhausted(profile, nowMs);
const periodEnd = profile?.period_end ?? null;

// team_endpoints is not readable by clients, so ask the usage route.
// team_endpoints and endpoint_daily_stats are not readable by clients, so ask
// the usage route.
useEffect(() => {
if (!exhausted || !accessToken) return;
let cancelled = false;
void fetch("/api/usage", { headers: { Authorization: `Bearer ${accessToken}` } })
.then((response) => (response.ok ? response.json() : null))
.then((usage: { teamBilledEndpoints?: number } | null) => {
if (!cancelled) setTeamBilledEndpoints(usage?.teamBilledEndpoints ?? 0);
.then((usage: { teamBilledEndpoints?: number; quotaRejections?: QuotaRejections } | null) => {
if (cancelled) return;
setTeamBilledEndpoints(usage?.teamBilledEndpoints ?? 0);
setRejections(usage?.quotaRejections ?? null);
})
.catch(() => {});
return () => {
Expand Down Expand Up @@ -103,6 +139,7 @@ export function QuotaBanner() {
{teamBilledEndpoints > 0 &&
" Endpoints shared with a team use the team's quota and keep capturing."}
</p>
<QuotaRejectionDetail rejections={rejections} />
<p className="text-sm text-muted-foreground">
{isFree
? "Pro raises the limit to 100,000 requests a month for $8/month. "
Expand Down
13 changes: 12 additions & 1 deletion apps/web/lib/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,24 @@ export const TRACK_LABELS: Record<ChangelogTrack, string> = {
mcp: "MCP",
};

export const APP_VERSION = "0.40.3";
export const APP_VERSION = "0.40.4";
export const CLI_VERSION = "1.4.1";
export const SDK_VERSION = "1.15.0";
export const MCP_VERSION = "1.12.1";

export const CHANGELOG: ChangelogEntry[] = [
// ─── Web App ────────────────────────────────────────────────────────
{
version: "0.40.4",
date: "2026-10-22",
title: "Rejected Webhook Counts in Quota Alerts",
track: "web",
items: [
"The quota-exhausted email and the dashboard quota banner now say how many webhooks were rejected this period and on which endpoints, most rejected first, with the rest summarized as a count",
"Rejections on endpoints billed to a subscribed team are left out, matching the existing note that those endpoints keep capturing",
"New get_quota_rejections() database function (migration 00060) reads the per-endpoint counts from endpoint_daily_stats; /api/usage returns them as quotaRejections while the quota is exhausted",
],
},
{
version: "0.40.3",
date: "2026-10-20",
Expand Down
88 changes: 88 additions & 0 deletions apps/web/lib/email/quota-exhausted-email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,92 @@ describe("buildQuotaExhaustedEmail", () => {
expect(message.html).toContain('href="https://webhooks.cc/account/plan"');
expect(message.html).toContain('href="https://webhooks.cc/teams"');
});
describe("rejected webhooks", () => {
const base = {
to: "dev@example.com",
requestLimit: 50,
periodEnd: new Date("2026-09-29T18:30:00Z"),
teamBilledEndpoints: 0,
appUrl: "https://webhooks.cc",
};

it("keeps the generic copy when nothing has been rejected yet", () => {
for (const rejections of [undefined, null, { total: 0, endpointCount: 0, endpoints: [] }]) {
const email = buildQuotaExhaustedEmail({ ...base, rejections });
expect(email.text).toBe(message.text);
expect(email.html).toBe(message.html);
}
});

it("lists the total and each affected endpoint", () => {
const email = buildQuotaExhaustedEmail({
...base,
rejections: {
total: 1420,
endpointCount: 2,
endpoints: [
{ slug: "stripe-prod", rejected: 1000 },
{ slug: "gh-staging", rejected: 420 },
],
},
});
expect(email.text).toContain(
"So far in this period 1,420 webhooks were rejected across 2 endpoints:"
);
expect(email.text).toContain(" stripe-prod: 1,000 rejected webhooks");
expect(email.text).toContain(" gh-staging: 420 rejected webhooks");
expect(email.text).not.toContain("more endpoint");
expect(email.html).toContain("<li><code>stripe-prod</code>: 1,000 rejected webhooks</li>");
expect(email.html).toContain("<li><code>gh-staging</code>: 420 rejected webhooks</li>");
});

it("uses singular wording for one webhook on one endpoint", () => {
const email = buildQuotaExhaustedEmail({
...base,
rejections: { total: 1, endpointCount: 1, endpoints: [{ slug: "solo", rejected: 1 }] },
});
expect(email.text).toContain("1 webhook was rejected across 1 endpoint:");
expect(email.text).toContain(" solo: 1 rejected webhook");
expect(email.text).not.toContain("1 rejected webhooks");
});

it("summarizes endpoints beyond the listed ones", () => {
const email = buildQuotaExhaustedEmail({
...base,
rejections: {
total: 900,
endpointCount: 4,
endpoints: [
{ slug: "a", rejected: 500 },
{ slug: "b", rejected: 300 },
],
},
});
expect(email.text).toContain(" and 2 more endpoints");
expect(email.html).toContain("<li>and 2 more endpoints</li>");
});

it("places the rejection list before the upgrade call to action", () => {
const email = buildQuotaExhaustedEmail({
...base,
rejections: { total: 5, endpointCount: 1, endpoints: [{ slug: "a", rejected: 5 }] },
});
expect(email.text.indexOf(" a: 5 rejected webhooks")).toBeLessThan(
email.text.indexOf("Upgrade to Pro")
);
});

it("escapes slugs in the HTML variant", () => {
const email = buildQuotaExhaustedEmail({
...base,
rejections: {
total: 2,
endpointCount: 1,
endpoints: [{ slug: `<img src=x onerror="alert(1)">`, rejected: 2 }],
},
});
expect(email.html).not.toContain("<img");
expect(email.html).toContain("&lt;img src=x onerror=&quot;alert(1)&quot;&gt;");
});
});
});
65 changes: 62 additions & 3 deletions apps/web/lib/email/quota-exhausted-email.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,62 @@
import type { QuotaRejections } from "@/lib/supabase/quota-rejections";
import type { EmailMessage } from "./mailer";

function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

function plural(count: number, one: string, many: string): string {
return `${count.toLocaleString("en-US")} ${count === 1 ? one : many}`;
}

/**
* The sentence and per-endpoint lines describing what was rejected, or null
* when nothing was: the email goes out within minutes of the quota running
* out, so the first send often precedes the first rejection.
*/
function rejectionLines(rejections: QuotaRejections | null | undefined): {
headline: string;
endpoints: { slug: string; rejected: string }[];
more: string | null;
} | null {
if (!rejections || rejections.total <= 0 || rejections.endpoints.length === 0) return null;

const remaining = Math.max(0, rejections.endpointCount - rejections.endpoints.length);
return {
headline:
`So far in this period ${plural(rejections.total, "webhook was", "webhooks were")} ` +
`rejected across ${plural(rejections.endpointCount, "endpoint", "endpoints")}:`,
endpoints: rejections.endpoints.map((endpoint) => ({
slug: endpoint.slug,
rejected: plural(endpoint.rejected, "rejected webhook", "rejected webhooks"),
})),
more: remaining > 0 ? `and ${plural(remaining, "more endpoint", "more endpoints")}` : null,
};
}

/**
* Builds the email sent when a free user's quota runs out and the receiver
* starts rejecting their webhooks. Pure (no I/O) so it is unit-testable; the
* caller sends it through sendEmail(). Every interpolated value comes from the
* database or env, not user input, so the HTML variant needs no escaping.
* caller sends it through sendEmail(). The limit, dates and counts come from
* the database or env. Endpoint slugs are escaped in the HTML variant anyway,
* since they are the one value a user could have chosen.
*/
export function buildQuotaExhaustedEmail(params: {
to: string;
requestLimit: number;
periodEnd: Date;
/** Owned endpoints billed to a team; they keep capturing and are named as unaffected. */
teamBilledEndpoints: number;
/** Rejected webhooks this period; omitted or empty leaves the generic copy. */
rejections?: QuotaRejections | null;
appUrl: string;
}): EmailMessage {
const { to, requestLimit, periodEnd, teamBilledEndpoints, appUrl } = params;
const { to, requestLimit, periodEnd, teamBilledEndpoints, rejections, appUrl } = params;
const accountLink = `${appUrl}/account/plan`;
const teamsLink = `${appUrl}/teams`;
const limit = requestLimit.toLocaleString("en-US");
Expand All @@ -23,6 +65,7 @@ export function buildQuotaExhaustedEmail(params: {
teamBilledEndpoints > 0
? "Endpoints shared with a team use the team's quota and keep capturing."
: null;
const rejected = rejectionLines(rejections);

return {
to,
Expand All @@ -32,6 +75,14 @@ export function buildQuotaExhaustedEmail(params: {
`Until the period resets at ${resetsAt}, new webhooks sent to your endpoints`,
`are rejected with HTTP 429 and are not captured.`,
...(teamNote ? [teamNote] : []),
...(rejected
? [
``,
rejected.headline,
...rejected.endpoints.map((e) => ` ${e.slug}: ${e.rejected}`),
...(rejected.more ? [` ${rejected.more}`] : []),
]
: []),
``,
`Upgrade to Pro for 100,000 requests a month and 31-day retention, $8/month:`,
accountLink,
Expand All @@ -47,6 +98,14 @@ export function buildQuotaExhaustedEmail(params: {
`are rejected with HTTP 429 and are not captured.` +
(teamNote ? ` ${teamNote}` : "") +
`</p>`,
...(rejected
? [
`<p>${rejected.headline}</p>`,
`<ul>${rejected.endpoints
.map((e) => `<li><code>${escapeHtml(e.slug)}</code>: ${e.rejected}</li>`)
.join("")}${rejected.more ? `<li>${rejected.more}</li>` : ""}</ul>`,
]
: []),
`<p><a href="${accountLink}">Upgrade to Pro</a> for 100,000 requests a month ` +
`and 31-day retention, $8/month.</p>`,
`<p>Capturing for a company? A <a href="${teamsLink}">team</a> gets a pooled quota ` +
Expand Down
Loading
Loading