<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Stack & Ship]]></title><description><![CDATA[Stack & Ship]]></description><link>https://stackandship.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 01 Nov 2026 04:51:16 GMT</lastBuildDate><atom:link href="https://stackandship.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[OpenAI and Anthropic 429 errors in Next.js: which to retry, which to stop]]></title><description><![CDATA[Originally published on the Stack & Ship blog ( Drafted with AI assistance and reviewed by the Stack & Ship team before publishing. We make the free AI API…]]></description><link>https://stackandship.hashnode.dev/openai-and-anthropic-429-errors-in-next-js-which-to-retry-which-to-stop</link><guid isPermaLink="true">https://stackandship.hashnode.dev/openai-and-anthropic-429-errors-in-next-js-which-to-retry-which-to-stop</guid><category><![CDATA[Next.js]]></category><category><![CDATA[openai]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[Stack & Ship]]></dc:creator><pubDate>Sat, 24 Oct 2026 09:07:45 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published on the Stack &amp; Ship blog (</em><a href="https://edtrackai.github.io/blog/openai-anthropic-429-rate-limit-error-nextjs"><em>https://edtrackai.github.io/blog/openai-anthropic-429-rate-limit-error-nextjs</em></a><em>). Drafted with AI assistance and reviewed by the Stack &amp; Ship team before publishing. We make the free AI API cost calculator linked at the end.</em></p>
<p>Your Next.js route calls OpenAI or Anthropic and gets a 429. The usual advice is to add a retry with backoff. That fixes some 429s and makes others worse, because both providers now use the same status for problems that clear in a second and problems that won't clear until someone changes a billing setting. This post shows how to tell them apart from the error code, what the official SDKs do by default, and a small helper that retries rate limits and overload but stops on the billing codes it knows.</p>
<p><strong>Checked 17 Oct 2026 with</strong> <code>openai</code> <strong>7.36.0 and</strong> <code>@anthropic-ai/sdk</code> <strong>0.135.0</strong> (Node 20.19, TypeScript strict). Error codes and their meanings come from the OpenAI and Anthropic docs, quoted below and listed in Sources. Providers add codes from time to time, so check the docs against the date above.</p>
<p><strong>What we did and didn't test.</strong> We didn't trigger a real 429. We ran both SDKs against a local mock server that returns error bodies and headers we rebuilt from the docs, so the bodies are our reconstruction, not captured failures. What the mock does show is real SDK behaviour: the error class each SDK throws, which fields it fills in, how many requests it sends with its default retries, and how it handles <code>retry-after</code>. Our helper type-checks with <code>tsc --strict</code> and has 20 unit tests. We aren't affiliated with or endorsed by OpenAI or Anthropic; their product and model names are their trademarks, used here only to identify them.</p>
<h2>Quick answer: read the code, not the status</h2>
<p>A 429 on its own doesn't tell you what to do. Look one level down:</p>
<ul>
<li><strong>OpenAI:</strong> read <code>error.code</code>. The error codes guide says: "For billing-related errors, inspect <code>error.code</code> to identify the specific cause. The broader <code>error.type</code> can still be <code>insufficient_quota</code>."</li>
<li><strong>Anthropic:</strong> read the error <code>type</code>, and check <code>error.details.error_code</code> for a spend cap.</li>
</ul>
<p>Then split the result into two groups:</p>
<ol>
<li><strong>Can clear by waiting:</strong> rate limits, ramp-up limits and overload. Retry a few times, honouring <code>Retry-After</code>.</li>
<li><strong>Won't clear by waiting:</strong> credits, quota and spend limits. OpenAI is direct about it: "Retrying billing, spend, or quota errors won't restore API access. Update the relevant credits or limits before sending another request." Stop, log it and show the user a clear message.</li>
</ol>
<h2>The OpenAI 429s</h2>
<p>The OpenAI error codes guide lists several 429 causes. Here they are with what each one likely needs from you:</p>
<table>
<thead>
<tr>
<th>What you see</th>
<th>Code</th>
<th>Retry?</th>
<th>What the docs say to do</th>
</tr>
</thead>
<tbody><tr>
<td>Rate limit reached for requests</td>
<td>(rate limit)</td>
<td>Yes, with a cap</td>
<td>"Pace your requests and follow the <code>Retry-After</code> header when it's present."</td>
</tr>
<tr>
<td>Slow down</td>
<td><code>slow_down</code></td>
<td>Yes, with a cap</td>
<td>Follow <code>Retry-After</code>, "reduce your request rate, and increase it gradually."</td>
</tr>
<tr>
<td>Credit balance exhausted</td>
<td><code>credit_balance_exhausted</code></td>
<td>No</td>
<td>"Add credits to continue using the API."</td>
</tr>
<tr>
<td>Project spend limit reached</td>
<td><code>project_spend_limit_exceeded</code></td>
<td>No</td>
<td>"Increase or remove the spend limit in your project settings."</td>
</tr>
<tr>
<td>Organization spend limit reached</td>
<td>organization spend code (below)</td>
<td>No</td>
<td>"Increase or remove your organization spend limit."</td>
</tr>
<tr>
<td>Organization usage limit reached</td>
<td>organization usage code (below)</td>
<td>No</td>
<td>"Request a higher approved usage limit or contact support."</td>
</tr>
</tbody></table>
<p>The two organization codes are too long for a table cell:</p>
<pre><code class="language-text">organization_spend_limit_exceeded
organization_usage_limit_exceeded
</code></pre>
<p>Two of these can surprise you:</p>
<ul>
<li><code>slow_down</code> <strong>can appear under your limits.</strong> The guide says it "can occur even when your traffic is within its requests-per-minute and tokens-per-minute limits." It's about how fast your traffic grows. As a rule of thumb, once traffic reaches 1 million input tokens per minute, the guide suggests you "increase it by no more than 50% every 15 minutes."</li>
<li><strong>A 429 on your first request</strong> is unlikely to be a rate limit. It's more likely one of the billing codes, which share the 429 status. Check <code>error.code</code> before you add retries.</li>
</ul>
<p>Overload isn't a 429 on OpenAI. The guide lists "503 - Model temporarily overloaded" with code <code>server_is_overloaded</code>, and notes that "Video overload, for example, now returns <code>503</code> where it previously returned <code>429</code>." If your handler only catches rate-limit errors, it may miss this one.</p>
<p>OpenAI also sends rate-limit headers on responses. The rate limits guide lists these, among others:</p>
<pre><code class="language-text">retry-after
x-ratelimit-limit-requests
x-ratelimit-remaining-requests
x-ratelimit-reset-requests
x-ratelimit-limit-tokens
x-ratelimit-remaining-tokens
x-ratelimit-reset-tokens
</code></pre>
<p>The same guide warns that <code>Retry-After</code> "does not mean that quota, billing, or other errors that require user action can be resolved by retrying."</p>
<h2>The Anthropic 429, 529 and the spend-limit 400</h2>
<p>Anthropic's errors page describes one 429 type that covers several causes:</p>
<blockquote>
<p>429 - <code>rate_limit_error</code>: Your organization has hit a rate limit, reached its usage tier's monthly spend cap, or reached a spend limit on the Claude Code workspace. A tier spend-cap 429 has no <code>retry-after</code> header and keeps failing until access resumes</p>
</blockquote>
<p>The rate limits page explains how to tell the spend cap apart: on the Messages API, <code>error.details.error_code</code> is <code>enforced_spend_limit_reached</code>. A real rate limit names the limit you hit (requests, input tokens or output tokens per minute), and "When the response has a <code>retry-after</code> header, wait that many seconds before retrying."</p>
<p>Three more cases are worth handling:</p>
<table>
<thead>
<tr>
<th>What you see</th>
<th>Status and type</th>
<th>Retry?</th>
</tr>
</thead>
<tbody><tr>
<td>Rate limit (RPM, ITPM or OTPM)</td>
<td>429 <code>rate_limit_error</code></td>
<td>Yes, with a cap</td>
</tr>
<tr>
<td>Tier spend cap</td>
<td>429 <code>rate_limit_error</code>, <code>enforced_spend_limit_reached</code></td>
<td>No</td>
</tr>
<tr>
<td>API overloaded</td>
<td>529 <code>overloaded_error</code></td>
<td>Yes, with a cap</td>
</tr>
<tr>
<td>Spend limit you set on the organization or workspace</td>
<td>400 <code>invalid_request_error</code></td>
<td>No</td>
</tr>
</tbody></table>
<p>The 400 one is easy to miss. The errors page says: "The API also returns a 400 when usage reaches an organization or workspace spend limit you set." It won't show up in a 429 handler at all.</p>
<p>The errors page also mentions acceleration limits: "if your organization has a sharp increase in usage, you might see 429 errors because of acceleration limits on the API. To avoid hitting acceleration limits, ramp up your traffic gradually and maintain consistent usage patterns."</p>
<p>Anthropic's rate-limit headers have longer names, for example:</p>
<pre><code class="language-text">retry-after
anthropic-ratelimit-requests-limit
anthropic-ratelimit-requests-remaining
anthropic-ratelimit-requests-reset
anthropic-ratelimit-input-tokens-remaining
</code></pre>
<h2>What the SDKs do by default (our mock test)</h2>
<p>Both SDKs retry for you. The OpenAI SDK README says: "Certain errors will be automatically retried 2 times by default, with a short exponential backoff." It lists "429 Rate Limit, and &gt;=500 Internal errors" among them. Anthropic's errors page says the SDK "automatically retries transient failures (such as connection errors, rate limits, and 5xx server errors) with exponential backoff, twice by default, honoring the <code>retry-after</code> header when present."</p>
<p>We counted the requests our mock server received. These are the results from our run:</p>
<ul>
<li><strong>Every 429 arrived as</strong> <code>RateLimitError</code> <strong>in both SDKs</strong>, including the credit, quota and spend-limit bodies. The class alone doesn't tell you whether to retry.</li>
<li><strong>With default settings, the mock received 3 requests for every 429, 503 and 529 body</strong>, including <code>insufficient_quota</code> and <code>credit_balance_exhausted</code> as well as the spend-limit codes and Anthropic's spend cap. So by default each failed user action can cost three calls that the docs say won't succeed. The Anthropic 400 got 1 request.</li>
<li><strong>With</strong> <code>maxRetries: 0</code><strong>, the mock received exactly 1 request</strong> for every case.</li>
<li><strong>OpenAI 503 and Anthropic 529 arrived as</strong> <code>InternalServerError</code>, not <code>RateLimitError</code>. The Anthropic 400 arrived as <code>BadRequestError</code>.</li>
<li><code>retry-after: 1</code> produced gaps of about one second in both SDKs.</li>
<li><code>retry-after: 120</code> was handled differently. <code>openai</code> 7.36.0 retried after about half a second and one second, so it didn't wait the 120 seconds. We didn't check whether that's a deliberate limit in the SDK; either way, don't rely on it to honour long waits. <code>@anthropic-ai/sdk</code> 0.135.0 waited over two minutes before each retry, so the call took about five minutes to fail. In a serverless route, a wait that long may hit your function timeout first.</li>
<li><strong>Where the code lives differs.</strong> In <code>openai</code>, <code>err.code</code> and <code>err.type</code> were set (for example <code>project_spend_limit_exceeded</code> and <code>insufficient_quota</code>). In <code>@anthropic-ai/sdk</code>, <code>err.code</code> was empty, and the type and details were inside <code>err.error.error</code>.</li>
</ul>
<p>Our mock bodies are reconstructions, and real responses can differ. The SDK behaviour above is what the code did with those bodies.</p>
<h2>One classifier for both SDKs</h2>
<p>This helper reads both SDKs' errors and returns what to do. It looks at spend and billing codes first, so a billing 429 with one of these codes is never treated as a rate limit. A code added later would be treated as a rate limit and retried within the caps, so check the docs for new codes:</p>
<pre><code class="language-ts">// lib/provider-error.ts
export type ProviderErrorKind =
  | "rate_limit" | "overloaded" | "quota_or_billing" | "spend_limit" | "other";
export type Classified = {
  kind: ProviderErrorKind; retryable: boolean;
  retryAfterMs: number | null; code: string | null;
};

const BILLING = new Set(["insufficient_quota", "credit_balance_exhausted",
  "organization_usage_limit_exceeded"]);
const SPEND = new Set(["project_spend_limit_exceeded",
  "organization_spend_limit_exceeded", "enforced_spend_limit_reached"]);
const SPEND_400 = "You have reached your specified"; // Anthropic 400 prefix

type Headersish = { get(name: string): string | null } | undefined;
const str = (v: unknown) =&gt; (typeof v === "string" ? v : null);

export function parseRetryAfter(h: Headersish, now = Date.now()): number | null {
  const ms = h?.get("retry-after-ms");
  if (ms &amp;&amp; Number.isFinite(Number(ms))) return Math.max(0, Number(ms));
  const v = h?.get("retry-after");
  if (!v) return null;
  const s = Number(v);
  if (Number.isFinite(s)) return Math.max(0, s * 1000);
  const d = Date.parse(v); // HTTP-date form
  return Number.isNaN(d) ? null : Math.max(0, d - now);
}

export function classifyProviderError(err: unknown): Classified {
  const e = (err ?? {}) as Record&lt;string, unknown&gt;;
  const status = typeof e.status === "number" ? e.status : undefined;
  const h = e.headers instanceof Headers ? e.headers : undefined;
  const body = (e.error ?? {}) as Record&lt;string, unknown&gt;;
  const inner = (body.error ?? body) as Record&lt;string, unknown&gt;; // Anthropic nests it
  const details = (inner.details ?? {}) as Record&lt;string, unknown&gt;;
  const code = str(details.error_code) ?? str(e.code) ?? str(inner.code);
  const type = str(e.type) ?? str(inner.type);
  const message = str(inner.message) ?? "";
  const retryAfterMs = parseRetryAfter(h);
  const out = (kind: ProviderErrorKind, retryable: boolean): Classified =&gt;
    ({ kind, retryable, retryAfterMs: retryable ? retryAfterMs : null, code: code ?? type });

  if (code &amp;&amp; SPEND.has(code)) return out("spend_limit", false);
  if (status === 400 &amp;&amp; message.startsWith(SPEND_400))
    return out("spend_limit", false);
  if ((code &amp;&amp; BILLING.has(code)) || type === "insufficient_quota")
    return out("quota_or_billing", false);
  if (status === 429) return out("rate_limit", true);
  if (status === 529 || status === 503 || type === "overloaded_error")
    return out("overloaded", true);
  return out("other", false);
}
</code></pre>
<p>The docs don't give a separate code for the Anthropic spend-limit 400, but the rate limits page says its message begins "You have reached your specified API usage limits" or "You have reached your specified workspace API usage limits". So the helper treats a 400 with that opening as <code>spend_limit</code>, not retryable, and the route below stops and returns the same message as for billing errors. If Anthropic rewords the message, it would fall back to <code>other</code>.</p>
<h2>A capped retry that stops on known billing errors</h2>
<p>Turn the SDK's own retries off with <code>maxRetries: 0</code>, so you don't stack two retry loops, and use one you control. This one caps attempts, caps each wait and the total wait, honours <code>Retry-After</code> inside those caps, and gives up straight away on anything not retryable:</p>
<pre><code class="language-ts">// lib/provider-retry.ts
import { classifyProviderError } from "./provider-error";

export async function withProviderRetry&lt;T&gt;(
  fn: () =&gt; Promise&lt;T&gt;,
  { maxAttempts = 3, maxTotalWaitMs = 20_000, maxSingleWaitMs = 10_000 } = {},
): Promise&lt;T&gt; {
  const attempts = Math.min(maxAttempts, 5);
  let waited = 0;
  for (let attempt = 1; ; attempt++) {
    try {
      return await fn();
    } catch (err) {
      const c = classifyProviderError(err);
      if (!c.retryable || attempt &gt;= attempts) throw err;
      const backoff = 500 * 2 ** (attempt - 1) * (1 - Math.random() * 0.25);
      const wait = c.retryAfterMs ?? backoff;
      // A Retry-After longer than our caps means "not now": fail fast.
      if (wait &gt; maxSingleWaitMs || waited + wait &gt; maxTotalWaitMs) throw err;
      waited += wait;
      await new Promise((r) =&gt; setTimeout(r, wait));
    }
  }
}
</code></pre>
<p>In our unit tests, a billing or spend error led to one call and no wait. A rate limit with <code>retry-after: 1</code> led to three calls with two one-second waits. A <code>retry-after: 120</code> failed at once without sleeping, and a 529 with no header backed off at 500 ms and then 1 s.</p>
<p>Pick the caps to fit your route's timeout. If your platform stops functions after a set time, keep the total wait well under it.</p>
<h2>Wiring it into a Next.js route</h2>
<p>The route takes the user id from the auth session, never from the request body, and returns a status your UI can act on:</p>
<pre><code class="language-ts">// app/api/chat/route.ts
import { createHash } from "node:crypto";
import OpenAI from "openai";
import { createClient } from "@/lib/supabase/server";
import { classifyProviderError } from "@/lib/provider-error";
import { withProviderRetry } from "@/lib/provider-retry";

const openai = new OpenAI({ maxRetries: 0 }); // our helper does the retrying

export async function POST(req: Request) {
  const supabase = await createClient();
  const { data: auth } = await supabase.auth.getClaims();
  const userId = auth?.claims.sub; // from the verified session only
  if (!userId) return Response.json({ error: "Sign in first" }, { status: 401 });
  // OpenAI gets a hash, not the raw id
  const safetyId = createHash("sha256").update(userId).digest("hex");

  const { message } = (await req.json()) as { message: string };
  try {
    const res = await withProviderRetry(() =&gt;
      openai.responses.create({
        model: process.env.OPENAI_MODEL!,
        input: message,
        safety_identifier: safetyId,
      }),
    );
    return Response.json({ text: res.output_text });
  } catch (err) {
    const c = classifyProviderError(err);
    console.error("provider error", { userId, kind: c.kind, code: c.code });
    if (c.kind === "rate_limit" || c.kind === "overloaded") {
      const secs = Math.ceil((c.retryAfterMs ?? 5_000) / 1000);
      return Response.json({ error: "Busy, try again shortly" },
        { status: 503, headers: { "Retry-After": String(secs) } });
    }
    if (c.kind === "quota_or_billing" || c.kind === "spend_limit") {
      return Response.json({ error: "AI is unavailable right now" }, { status: 503 });
    }
    return Response.json({ error: "Something went wrong" }, { status: 500 });
  }
}
</code></pre>
<p>A few choices here are deliberate:</p>
<ul>
<li><strong>Your users get a 503, not the provider's 429.</strong> A 429 from your route should mean "this user is sending too much". When the provider is the problem, that's a service issue on your side.</li>
<li><strong>Known billing errors are logged, not retried</strong>, and the message doesn't expose your account state to users. Alert yourself on <code>quota_or_billing</code> and <code>spend_limit</code>.</li>
<li><strong>OpenAI gets a hash of the user id, not the raw id.</strong> OpenAI suggests hashing the identifier you send, so we pass a SHA-256 hash of the user id as <code>safety_identifier</code>.</li>
<li><strong>The same pattern works for Anthropic.</strong> Create the Anthropic client with <code>maxRetries: 0</code> and call <code>messages.create</code>. The classifier already reads the nested Anthropic error.</li>
</ul>
<h2>Why one user can cause 429s for everyone</h2>
<p>OpenAI's error codes guide says that "limits are applied per organization and not per user." Anthropic's limits are also set at the organization level. If one signed-in user scripts your chat route, they use up the shared requests and tokens per minute, and every other user starts seeing errors.</p>
<p>Retries don't help with that. What helps is a limit per user in your own app, keyed on the session user id, so one account can't use up the shared allowance. Our <a href="https://edtrackai.github.io/blog/openai-anthropic-spend-limit-per-user-nextjs">per-user spend limits post</a> walks through a per-user rate limit and a monthly dollar cap in Supabase.</p>
<h2>Reduce rate-limit pressure with prompt caching</h2>
<p>On Anthropic, cached reads can take pressure off your input-token limit. The rate limits page says: "For most Claude models, only uncached input tokens count toward your ITPM rate limits." If your prompts share a long prefix, getting caching to work can leave more room under that limit. Our <a href="https://edtrackai.github.io/blog/prompt-caching-not-working-openai-anthropic-nextjs">prompt caching post</a> covers how to check whether requests actually hit the cache.</p>
<h2>Checklist</h2>
<ul>
<li>Read <code>error.code</code> (OpenAI) or the type and <code>details.error_code</code> (Anthropic) before deciding to retry.</li>
<li>Never retry credit, quota or spend-limit errors. Alert on them.</li>
<li>Set <code>maxRetries: 0</code> on the SDK if you run your own retry loop, so the two don't stack.</li>
<li>Cap attempts, each wait and the total wait. Honour <code>Retry-After</code> only within those caps.</li>
<li>Handle OpenAI 503 and Anthropic 529 as overload, and the Anthropic spend-limit 400 as not retryable.</li>
<li>Take the user id from the session, and limit each user so one account can't exhaust the organization's limits.</li>
</ul>
<h2>Related</h2>
<ul>
<li><a href="https://edtrackai.github.io/blog/openai-anthropic-spend-limit-per-user-nextjs">OpenAI and Anthropic spend limits per user in Next.js</a>: a per-user rate limit and a monthly dollar cap in Supabase.</li>
<li><a href="https://edtrackai.github.io/blog/prompt-caching-not-working-openai-anthropic-nextjs">Prompt caching not working? Check it in OpenAI and Anthropic (Next.js)</a>: how to confirm cache hits and price cached tokens.</li>
</ul>
<p><strong>Sources (checked 17 October 2026):</strong></p>
<ul>
<li><a href="https://platform.openai.com/docs/guides/error-codes">OpenAI: Error codes</a></li>
<li><a href="https://platform.openai.com/docs/guides/rate-limits">OpenAI: Rate limits</a></li>
<li><a href="https://github.com/openai/openai-node#retries">OpenAI Node SDK README (Retries)</a></li>
<li><a href="https://platform.claude.com/docs/en/api/errors">Anthropic: Errors</a></li>
<li><a href="https://platform.claude.com/docs/en/api/rate-limits">Anthropic: Rate limits</a></li>
<li><a href="https://github.com/anthropics/anthropic-sdk-typescript">Anthropic TypeScript SDK</a></li>
</ul>
<hr />
<p>Disclosure: we make this. Our free AI API cost calculator estimates per-call spend before you retry: <a href="https://edtrackai.github.io/tools/ai-api-cost-calculator?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=sprint1&amp;utm_content=e57-openai-anthropic-429-rate-limit-error-nextjs">https://edtrackai.github.io/tools/ai-api-cost-calculator?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=sprint1&amp;utm_content=e57-openai-anthropic-429-rate-limit-error-nextjs</a></p>
]]></content:encoded></item><item><title><![CDATA[How to Track MRR and Monthly Profit per Product in Notion (Free Template)]]></title><description><![CDATA[Relations, formulas and rollups that calculate MRR and monthly profit for each product. Works on Notion's free plan.]]></description><link>https://stackandship.hashnode.dev/track-mrr-and-monthly-profit-per-product-in-notion</link><guid isPermaLink="true">https://stackandship.hashnode.dev/track-mrr-and-monthly-profit-per-product-in-notion</guid><category><![CDATA[notion]]></category><category><![CDATA[indiehackers]]></category><category><![CDATA[SaaS]]></category><category><![CDATA[Productivity]]></category><category><![CDATA[startup]]></category><dc:creator><![CDATA[Stack & Ship]]></dc:creator><pubDate>Sat, 24 Oct 2026 05:12:48 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6adb10148ecc3ec9e22532d9/ab2d816e-2072-4b75-a86a-db9f4efc262e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>Disclosure: I'm the maker of Solo Founder OS (Stack &amp; Ship). This post was first published at <a href="https://edtrackai.github.io/blog/notion-mrr-profit-tracker?utm_source=hashnode&amp;utm_medium=crosspost&amp;utm_campaign=sprint1&amp;utm_content=notion-mrr">https://edtrackai.github.io/blog/notion-mrr-profit-tracker?utm_source=hashnode&amp;utm_medium=crosspost&amp;utm_campaign=sprint1&amp;utm_content=notion-mrr</a>. You can build everything below yourself on Notion's free plan. Lite is free; revenue, MRR and profit tracking are in Pro.</p>
</blockquote>
<p><a href="https://luxurydrive0.gumroad.com/l/wmhbge?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=sprint1&amp;utm_content=seo-notion-mrr-profit-tracker">Get Solo Founder OS Lite: free (pay what you want)</a></p>
<p>Lite is designed to track projects, tasks, and tool subscriptions. Revenue, MRR, and profit tracking are in Pro.</p>
<p>If you'd rather not build this yourself, <a href="https://luxurydrive0.gumroad.com/l/zsgkig?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=sprint1&amp;utm_content=seo-notion-mrr-profit-tracker">Solo Founder OS Pro</a> already does.</p>
<p>This guide pairs with <a href="https://edtrackai.github.io/solo-founder-os">Solo Founder OS</a>, and if you want the next step, read <a href="https://edtrackai.github.io/blog/notion-subscription-tracker-template">How to Track Subscriptions in Notion (Free Template)</a>.</p>
<p>If you run more than one product, a single revenue total hides what's actually working. One app might pay for everything while another quietly costs you £40 a month in hosting. This guide shows how to calculate <strong>MRR and monthly profit per product</strong> in Notion using relations, formulas and rollups. It works on the free plan.</p>
<h2>The structure</h2>
<p>You need four databases, each linked to a central <strong>Products</strong> database:</p>
<table>
<thead>
<tr>
<th>Database</th>
<th>Key properties</th>
</tr>
</thead>
<tbody><tr>
<td>Products</td>
<td>Name, Status, rollups (added in step 4)</td>
</tr>
<tr>
<td>Revenue</td>
<td>Date, Kind (Recurring / One-off), Gross, VAT, Fees, Product (relation)</td>
</tr>
<tr>
<td>Expenses</td>
<td>Date, Amount, Category, Product (relation)</td>
</tr>
<tr>
<td>Subscriptions</td>
<td>Cost, Billing (Monthly / Yearly), Status, Next renewal, Product (relation)</td>
</tr>
</tbody></table>
<h2>Step 1: Create Products</h2>
<p>One row per product, side project or client. Add a <code>Status</code> select (Idea, Building, Launched, Paused). That's all for now. The useful columns come from rollups later.</p>
<h2>Step 2: Revenue with net and MRR formulas</h2>
<p>Add a <code>Product</code> relation to Products (two-way, so Products gets a <code>Revenue</code> column). Then add these formulas:</p>
<pre><code class="language-plaintext">Net = prop("Gross") - prop("VAT") - prop("Fees")

MRR = if(prop("Kind") == "Recurring", prop("Gross") - prop("VAT"), 0)

MRR this month = if(
  formatDate(prop("Date"), "YYYY-MM") == formatDate(today(), "YYYY-MM"),
  prop("MRR"), 0)

Net this month = if(
  formatDate(prop("Date"), "YYYY-MM") == formatDate(today(), "YYYY-MM"),
  prop("Net"), 0)
</code></pre>
<p>Why exclude VAT from MRR? If you sell through a merchant of record such as Gumroad, Paddle or Lemon Squeezy, the VAT was never yours. Including it inflates your numbers.</p>
<h2>Step 3: Expenses and subscriptions</h2>
<p>In Expenses add <code>Spend this month</code> using the same month comparison on <code>Amount</code>. In Subscriptions, normalise everything to a monthly figure:</p>
<pre><code class="language-plaintext">Cost per month = if(prop("Status") == "Cancelled", 0,
  if(prop("Billing") == "Yearly", round(prop("Cost") / 12 * 100) / 100, prop("Cost")))
</code></pre>
<h2>Step 4: Roll it up on Products</h2>
<p>On Products, add rollups (all <strong>Sum</strong>):</p>
<ul>
<li><code>MRR</code> ← Revenue → MRR this month</li>
<li><code>Revenue this month</code> ← Revenue → Net this month</li>
<li><code>Expenses this month</code> ← Expenses → Spend this month</li>
<li><code>Tool costs / month</code> ← Subscriptions → Cost per month</li>
</ul>
<p>Then one formula:</p>
<pre><code class="language-plaintext">Monthly profit = prop("Revenue this month") - prop("Expenses this month") - prop("Tool costs / month")
</code></pre>
<p>Turn on the column <strong>Sum</strong> at the bottom of a table view to get business-wide totals.</p>
<p>Want the subscription side ready-made? <a href="https://luxurydrive0.gumroad.com/l/wmhbge?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=sprint1&amp;utm_content=seo-notion-mrr-profit-tracker">Solo Founder OS Lite</a> includes a Subscriptions database with cost per month and renewal dates, free (pay what you want).</p>
<h2>Limitations to know</h2>
<ul>
<li><strong>No currency conversion.</strong> Notion adds up numbers as entered. Pick a home currency and convert before entering; keep a Currency tag for reference.</li>
<li><strong>Manual entry.</strong> Notion doesn't pull from Stripe or Gumroad natively. Log weekly, or use Zapier/Make.</li>
<li><strong>"This month" is calendar-based.</strong> Annual plans paid upfront show as a spike unless you enter them as monthly instalments.</li>
<li>It's tracking, not accounting. Your accountant still needs proper records.</li>
</ul>
<p><strong>Want this pre-built?</strong> <a href="https://edtrackai.github.io/solo-founder-os">Solo Founder OS</a> includes all of the above plus tasks, bugs, releases and a dashboard. <a href="https://luxurydrive0.gumroad.com/l/wmhbge?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=sprint1&amp;utm_content=seo-notion-mrr-profit-tracker">Lite is free</a>; <a href="https://luxurydrive0.gumroad.com/l/zsgkig?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=sprint1&amp;utm_content=seo-notion-mrr-profit-tracker">Pro is $29 (plus VAT where applicable)</a>.</p>
<h2>Start free with Solo Founder OS Lite</h2>
<p>Lite is a free Notion template with 3 connected databases (Projects, Tasks, and Subscriptions) and a weekly review checklist. It works on the free Notion plan. Enter $0 at checkout, or pay what you want.</p>
<p><a href="https://luxurydrive0.gumroad.com/l/wmhbge?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=sprint1&amp;utm_content=seo-notion-mrr-profit-tracker">Get Solo Founder OS Lite: free (pay what you want)</a></p>
<p>Need per-product MRR, monthly profit, runway, churn, and a 6-month straight-line forecast? <a href="https://luxurydrive0.gumroad.com/l/zsgkig?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=sprint1&amp;utm_content=seo-notion-mrr-profit-tracker">Get Solo Founder OS Pro: $29 (plus VAT where applicable)</a>. You log sales by hand or with your own automation.</p>
<p>Free download by email. No marketing emails unless you <a href="https://luxurydrive0.gumroad.com/subscribe?utm_source=site&amp;utm_medium=web&amp;utm_campaign=list&amp;utm_content=blog-end">follow us on Gumroad</a>.</p>
<p>Solo Founder OS is our own product.</p>
<h2>Related posts</h2>
<ul>
<li><a href="https://edtrackai.github.io/blog/notion-subscription-tracker-template">How to Track Subscriptions in Notion (Free Template)</a></li>
<li><a href="https://edtrackai.github.io/blog/notion-income-tracker-template">Notion Income Tracker for Solo Founders (Free Build Guide)</a></li>
<li><a href="https://edtrackai.github.io/blog/notion-expense-tracker-template">Notion Expense Tracker for Solo Founders (Free Template)</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Supabase RLS policies that pass review but still leak data]]></title><description><![CDATA[Originally published on the Stack & Ship blog ( Drafted with AI assistance and reviewed by the Stack & Ship team before publishing. We make the free…]]></description><link>https://stackandship.hashnode.dev/supabase-rls-policies-that-pass-review-but-still-leak-data</link><guid isPermaLink="true">https://stackandship.hashnode.dev/supabase-rls-policies-that-pass-review-but-still-leak-data</guid><category><![CDATA[Next.js]]></category><category><![CDATA[supabase]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[Stack & Ship]]></dc:creator><pubDate>Fri, 23 Oct 2026 07:48:00 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published on the Stack &amp; Ship blog (<a href="https://edtrackai.github.io/blog/supabase-rls-policies-pass-review-leak-data">https://edtrackai.github.io/blog/supabase-rls-policies-pass-review-leak-data</a>). Drafted with AI assistance and reviewed by the Stack &amp; Ship team before publishing. We make the free security checker linked at the end.</em></p>
<p>Row Level Security is switched on. Every table has a policy. The policies mention <code>auth.uid()</code>. A reviewer skims the migration, sees all three, and approves it. And one signed-in user can still read every other user's row.</p>
<p>This post covers two patterns like that. Both came from readers of our earlier Dev.to article, <a href="https://dev.to/edtrackai/7-security-bugs-ai-agents-write-in-nextjs-supabase-stripe-apps-and-the-rules-that-stop-them-3o64">7 security bugs AI agents write in Next.js + Supabase + Stripe apps</a>, and both are easy to miss because they <em>look</em> right:</p>
<ol>
<li>A policy that checks the user is signed in, but not that the row is theirs.</li>
<li>A free-credit grant that treats a new account as a new person, which Gmail dot and plus aliases get around.</li>
</ol>
<p>All the SQL below was run on a local PostgreSQL 17 database with a stand-in <code>auth.uid()</code> function. Supabase behaviour is quoted from the <a href="https://supabase.com/docs/guides/database/postgres/row-level-security">Supabase RLS docs</a>, checked on 15 October 2026. This site isn't affiliated with Supabase.</p>
<h2>The policy that checks you're logged in, not that the row is yours</h2>
<p><a href="https://dev.to/cekuu35/comment/3hmng">cekuu35 pointed this out</a> in the comments. Our article suggested sweeping for <code>qual = 'true'</code>, and they noted that's only the first pass. Once an agent is told not to write <code>using (true)</code>, it tends to write this instead:</p>
<pre><code class="language-sql">create policy "Users can view profiles"
  on public.profiles for select
  to authenticated
  using (auth.uid() is not null);
</code></pre>
<p>It reads well. RLS is on, there's a policy, and the policy mentions auth. A <code>qual = 'true'</code> check won't flag it, because the expression isn't literally <code>true</code>.</p>
<p>But look at what it actually tests. <code>auth.uid() is not null</code> is true for <em>every</em> signed-in user, on <em>every</em> row. Nothing ties the row to the person asking. So any logged-in user can query the table with the anon/publishable key and get everyone's email, <code>stripe_customer_id</code>, and whatever else is in the table. One line in the browser console is enough:</p>
<pre><code class="language-ts">await supabase.from('profiles').select('*')
</code></pre>
<p>In our local test, a signed-in user with this policy saw 2 of 2 rows, including the other user's.</p>
<p>The policy is only correct when the table really is meant to be readable by every logged-in user, like a public directory. For anything per-user, it's a leak.</p>
<h2>Why reviews and linters miss it</h2>
<p>The usual checks all pass:</p>
<ul>
<li><strong>RLS enabled?</strong> Yes.</li>
<li><strong>A policy exists for each operation?</strong> Yes.</li>
<li><strong>No <code>using (true)</code>?</strong> Correct, there isn't one.</li>
<li><strong>Mentions <code>auth.uid()</code>?</strong> It does.</li>
</ul>
<p>None of those ask the question that matters: <em>does the expression compare the row's owner column to the caller?</em> The Supabase docs show the owner-scoped shape for every operation. For select it's:</p>
<pre><code class="language-sql">using ( (select auth.uid()) = user_id )
</code></pre>
<p>Insert uses the same comparison in <code>with check</code>. The docs also point out that <code>auth.uid()</code> returns <code>null</code> for unauthenticated requests. That's why a not-null test feels like a security check. It does stop signed-out users, but it stops nobody else.</p>
<h2>Find these policies with pg_policies</h2>
<p>Postgres exposes every policy in the <a href="https://www.postgresql.org/docs/current/view-pg-policies.html"><code>pg_policies</code></a> view. <code>qual</code> holds the <code>USING</code> expression and <code>with_check</code> holds the <code>WITH CHECK</code> expression, both as text. This query lists policies in <code>public</code> where either expression doesn't compare <code>auth.uid()</code> to something with <code>=</code>. That catches bare <code>true</code>, <code>auth.uid() is not null</code>, and anything else that never ties the row to the caller:</p>
<pre><code class="language-sql">select schemaname, tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
  and (
       (qual is not null
        and qual !~* 'auth\.uid\(\)[^=]*=|=[^=]*auth\.uid\(\)')
    or (with_check is not null
        and with_check !~* 'auth\.uid\(\)[^=]*=|=[^=]*auth\.uid\(\)')
  );
</code></pre>
<p>Run it in the Supabase SQL editor. On our test database, it returned the <code>auth.uid() is not null</code> select policy and a <code>with check (true)</code> insert policy, and 0 rows after the fix below. Postgres stores <code>(select auth.uid())</code> as <code>( SELECT auth.uid() AS uid)</code>; the pattern allows for that.</p>
<p>Treat the results as a review list, not a verdict. It's a text match, so:</p>
<ul>
<li><strong>Expected hits:</strong> genuinely public tables, and team tables scoped through an <code>exists (...)</code> membership check. Check each one and make sure it's intentional.</li>
<li><strong>What it can't see:</strong> an <code>=</code> that compares <code>auth.uid()</code> to the <em>wrong</em> column will pass. So will a policy that joins through an unfiltered table. The regex also passes any expression that contains a comparison somewhere, even when something else makes it broad. Both of these return 0 rows but scope nothing:</li>
</ul>
<pre><code class="language-sql">using ( auth.uid() is not null or &lt;anything&gt; )
using ( auth.uid() = auth.uid() )
</code></pre>
<p>cekuu35's comment includes their own version of the regex and a runnable red/green demo. The extra step we'd add is a two-user test: sign in as user A and try to read and update user B's row. That catches the shapes a pattern match won't.</p>
<h2>The fix: scope USING and WITH CHECK to the owner</h2>
<p>Replace the not-null test with a comparison against the owner column, one policy per operation the app needs:</p>
<pre><code class="language-sql">drop policy "Users can view profiles" on public.profiles;

create policy "Owners read own profile"
  on public.profiles for select
  to authenticated
  using ( (select auth.uid()) = user_id );

create policy "Owners insert own profile"
  on public.profiles for insert
  to authenticated
  with check ( (select auth.uid()) = user_id );

create policy "Owners update own profile"
  on public.profiles for update
  to authenticated
  using ( (select auth.uid()) = user_id )
  with check ( (select auth.uid()) = user_id );

create policy "Owners delete own profile"
  on public.profiles for delete
  to authenticated
  using ( (select auth.uid()) = user_id );
</code></pre>
<p>Leave out the delete policy if users shouldn't delete their own rows; with RLS on, no policy means no deletes.</p>
<p>Some notes from the Supabase docs:</p>
<ul>
<li><strong><code>using</code> vs <code>with check</code>:</strong> <code>using</code> decides which existing rows a user can see or target. <code>with check</code> decides what a new or updated row is allowed to look like. On updates, you need both: without <code>with check</code>, a user could update their row and set <code>user_id</code> to someone else's. In our test, that reassignment failed with this error, which is what you want:</li>
</ul>
<pre><code>new row violates row-level security policy for table "profiles"
</code></pre>
<ul>
<li><strong>The <code>(select ...)</code> wrapper:</strong> wrapping <code>auth.uid()</code> in <code>(select ...)</code> lets Postgres evaluate it once per statement instead of once per row. It's a performance detail, not a security one, but it costs nothing.</li>
<li><strong><code>to authenticated</code>:</strong> this keeps the anon role out entirely. Signed-out requests then can't match the policy at all.</li>
</ul>
<p>If you hit that same error on inserts after tightening policies, a common cause is <code>.insert().select()</code> (or an upsert): returning the new row also needs a matching select policy, so keep the select policy alongside the insert one.</p>
<h2>Free credits and Gmail aliases: a new account isn't a new person</h2>
<p><a href="https://dev.to/elijahbrown/comment/3hmo9">elijahbrown raised the second pattern</a> in the same thread. Treating "account just created" as proof someone should get free credits doesn't hold up. Anyone can sign up again with <code>jane.doe+2@gmail.com</code> or <code>j.a.n.e.doe@gmail.com</code> and look brand new. Gmail ignores dots in the local part and everything after a <code>+</code>, so all of those reach the same inbox.</p>
<p>This isn't an RLS bug, strictly speaking. It's the same mistake, though: trusting something that looks like identity but isn't. Their suggested fix is to key the credit grant on a <strong>normalised</strong> email in a separate claims table with a unique index, and only grant the credits when that insert succeeds.</p>
<p>Here's a normalisation function. It lowercases every address. For <code>gmail.com</code> and <code>googlemail.com</code> it also strips dots and any <code>+suffix</code> from the local part, and maps both domains to <code>gmail.com</code>:</p>
<pre><code class="language-sql">create or replace function public.normalize_email(addr text)
returns text
language sql
immutable
strict
as $$
  select case
    when split_part(lower(trim(addr)), '@', 2) in ('gmail.com', 'googlemail.com')
      then replace(split_part(split_part(lower(trim(addr)), '@', 1), '+', 1), '.', '')
           || '@gmail.com'
    else lower(trim(addr))
  end
$$;
</code></pre>
<p>It's marked <code>immutable</code> so it can feed a stored generated column; Postgres only allows immutable functions in <a href="https://www.postgresql.org/docs/current/ddl-generated-columns.html">generated columns</a>. Then the claims table:</p>
<pre><code class="language-sql">create table public.trial_claims (
  id               bigint generated always as identity primary key,
  user_id          uuid unique references auth.users (id) on delete set null,
  email            text not null,
  email_normalized text generated always as (public.normalize_email(email)) stored,
  claimed_at       timestamptz not null default now()
);

create unique index trial_claims_email_normalized_key
  on public.trial_claims (email_normalized);

alter table public.trial_claims enable row level security;
-- No policies for anon/authenticated: only your server code writes here.
</code></pre>
<p>There's deliberately no <code>on delete cascade</code>: if deleting an account also deleted its claim, the same inbox could delete, sign up again and claim a second time. With <code>on delete set null</code>, the claim row (and its normalised email) stays after the account is gone. The <code>unique</code> on <code>user_id</code> also stops one account claiming twice.</p>
<p>Grant credits only when the claim row is actually inserted:</p>
<pre><code class="language-sql">insert into public.trial_claims (user_id, email)
values ($1, $2)
on conflict do nothing
returning user_id;
</code></pre>
<p>If the <code>returning</code> comes back empty, someone already claimed with that inbox, or this account already claimed. (<code>on conflict do nothing</code> with no target covers both unique checks.) In our test, <code>Jane.Doe+x@GoogleMail.com</code> and <code>j.a.n.e.doe@gmail.com</code> both normalised to <code>janedoe@gmail.com</code>, and the second claim inserted nothing. Because the unique index does the check, two signups racing each other can't both win.</p>
<p>Run the insert from server code, such as a Route Handler, Server Action, or webhook, using the email from the verified session, not one sent by the browser.</p>
<p>Limits worth being honest about:</p>
<ul>
<li><strong>Gmail is easy; other domains aren't.</strong> Some other providers support plus addressing too, and you could extend the function, but rules vary by provider. Lowercasing everything else is the safe default.</li>
<li><strong>Disposable email domains get past this entirely.</strong> Keep the free credit small, or put larger grants behind a verified payment method.</li>
<li><strong>Store the original email too.</strong> Use the normalised one only for the uniqueness check, never as the address you send to.</li>
</ul>
<h2>A quick checklist</h2>
<ul>
<li>Run the <code>pg_policies</code> query above and review every row it returns.</li>
<li>Every per-user table: an owner comparison in <code>using</code> on select, update, and delete, and in <code>with check</code> on insert and update.</li>
<li><code>to authenticated</code> on policies that should never apply to signed-out users.</li>
<li>A two-user test in CI: user A can't read or update user B's row.</li>
<li>Free credits keyed on a normalised email with a unique index, granted only when the insert succeeds.</li>
</ul>
<h2>Related</h2>
<ul>
<li>If you see <code>permission denied for table</code> instead, that's a missing grant, not RLS. See <a href="https://edtrackai.github.io/blog/supabase-permission-denied-for-table-data-api-grants">the Supabase Data API grants change</a>.</li>
</ul>
<p>Thanks to <a href="https://dev.to/cekuu35">cekuu35</a> and <a href="https://dev.to/elijahbrown">elijahbrown</a> for raising these in the <a href="https://dev.to/edtrackai/7-security-bugs-ai-agents-write-in-nextjs-supabase-stripe-apps-and-the-rules-that-stop-them-3o64#comments">comment thread</a>.</p>
<p><strong>Sources (checked 15 October 2026):</strong></p>
<ul>
<li><a href="https://supabase.com/docs/guides/database/postgres/row-level-security">Supabase: Row Level Security</a></li>
<li><a href="https://www.postgresql.org/docs/current/view-pg-policies.html">PostgreSQL: pg_policies</a></li>
<li><a href="https://www.postgresql.org/docs/current/ddl-generated-columns.html">PostgreSQL: Generated columns</a></li>
<li>Dev.to comments by <a href="https://dev.to/cekuu35/comment/3hmng">cekuu35</a> and <a href="https://dev.to/elijahbrown/comment/3hmo9">elijahbrown</a></li>
</ul>
<hr />
<p>Disclosure: we make this. Our free Next.js + Supabase + Stripe security checker flags tables without RLS, using (true) / with check (true) write policies, anon write policies and missing grants in your migration files. It's heuristic and won't catch every policy shape above: <a href="https://edtrackai.github.io/checker?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=sprint1&amp;utm_content=e57-supabase-rls-policies-pass-review-leak-data">https://edtrackai.github.io/checker?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=sprint1&amp;utm_content=e57-supabase-rls-policies-pass-review-leak-data</a></p>
]]></content:encoded></item></channel></rss>