<?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[Ship It Live]]></title><description><![CDATA[Ship It Live]]></description><link>https://shipitlive.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Ship It Live</title><link>https://shipitlive.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 19 Oct 2026 06:07:51 GMT</lastBuildDate><atom:link href="https://shipitlive.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Billing in a Small SaaS: Webhooks, Entitlements and Licence Keys]]></title><description><![CDATA[TL;DR. In a small SaaS, billing breaks less often at checkout than in the gap between "someone paid" and "this person can use the thing". A simple way to close that gap: let your payment provider's we]]></description><link>https://shipitlive.hashnode.dev/billing-in-a-small-saas-webhooks-entitlements-and-licence-keys</link><guid isPermaLink="true">https://shipitlive.hashnode.dev/billing-in-a-small-saas-webhooks-entitlements-and-licence-keys</guid><category><![CDATA[SaaS]]></category><category><![CDATA[webhooks]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[architecture]]></category><dc:creator><![CDATA[Shaheer Malik]]></dc:creator><pubDate>Fri, 16 Oct 2026 05:33:21 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac3d9b8e606e4396d1e05e1/aa862e51-f147-496b-a805-105e34f9921e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>TL;DR.</strong> In a small SaaS, billing breaks less often at checkout than in the gap between "someone paid" and "this person can use the thing". A simple way to close that gap: let your payment provider's webhooks write to one entitlements table, have every paid feature ask that table, and, if you sell to developers, treat a licence key as just another way of finding an entitlement. The code in this post is example code to show the shape of the idea. It isn't lifted from a shipped product.</p>
<h2>The problem</h2>
<p>When you add paid plans to an MVP, three things have to agree:</p>
<ul>
<li>The payment provider knows who paid and whether their subscription is active.</li>
<li>Your database needs to know what each customer is allowed to use.</li>
<li>Your app has to check that before serving anything paid.</li>
</ul>
<p>If each part keeps its own idea of "paid", they drift. A cancelled customer keeps access, or a new customer pays and gets nothing. Both cost you trust.</p>
<h2>The pattern in one picture</h2>
<ul>
<li>The payment provider sends a webhook when something changes (a purchase, a renewal, a cancellation, a failed payment).</li>
<li>Your webhook handler verifies the request and updates one entitlements row.</li>
<li>Every paid route asks one function, <code>hasAccess()</code>, which reads that row.</li>
<li>For developer tools that have no login, a licence key maps to the same entitlement.</li>
</ul>
<p>That way there's one source of truth on your side, and one place to look when something goes wrong.</p>
<h2>1. Store entitlements, not payment events</h2>
<p>Keep the table small. It records what a customer may use now, not the whole payment history (your provider already keeps that).</p>
<p>Example code, not from a shipped product.</p>
<pre><code class="language-sql">CREATE TABLE IF NOT EXISTS entitlements (
  customer_id  TEXT PRIMARY KEY,
  plan         TEXT NOT NULL,      -- e.g. 'free', 'pro'
  status       TEXT NOT NULL,      -- e.g. 'active', 'past_due', 'cancelled'
  current_until TEXT,              -- end of the paid period, ISO date
  updated_at   INTEGER NOT NULL
);
</code></pre>
<h2>2. Verify every webhook before trusting it</h2>
<p>Anyone can POST to your webhook URL, so check the signature first. Most providers sign the raw request body with a shared secret. The exact header and format differ by provider, so follow their docs. The general shape looks like this:</p>
<p>Example code, not from a shipped product. Check your provider's docs for the real header name and signing scheme.</p>
<pre><code class="language-ts">async function verifySignature(rawBody: string, signature: string, secret: string) {
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["verify"],
  );
  const sig = Uint8Array.from(signature.match(/../g)!.map((h) =&gt; parseInt(h, 16)));
  return crypto.subtle.verify("HMAC", key, sig, new TextEncoder().encode(rawBody));
}
</code></pre>
<p>Two habits help here:</p>
<ul>
<li>Read the raw body before parsing it. Re-serialised JSON won't match the signature.</li>
<li>Make the handler idempotent. Providers retry, so the same event can arrive more than once. Writing "the current state" (an upsert) rather than "add one month" makes repeats harmless.</li>
</ul>
<h2>3. Turn events into one upsert</h2>
<p>Example code, not from a shipped product. Event names are placeholders; map your provider's real event types.</p>
<pre><code class="language-ts">type BillingEvent = {
  type: "subscription.active" | "subscription.past_due" | "subscription.cancelled";
  customerId: string;
  plan: string;
  currentUntil: string | null;
};

async function applyEvent(db: D1Database, e: BillingEvent) {
  const status = e.type.split(".")[1];
  await db
    .prepare(
      `INSERT INTO entitlements (customer_id, plan, status, current_until, updated_at)
       VALUES (?1, ?2, ?3, ?4, ?5)
       ON CONFLICT(customer_id) DO UPDATE SET
         plan = excluded.plan, status = excluded.status,
         current_until = excluded.current_until, updated_at = excluded.updated_at`,
    )
    .bind(e.customerId, e.plan, status, e.currentUntil, Date.now())
    .run();
}
</code></pre>
<h2>4. One access check for every paid feature</h2>
<p>Don't scatter <code>if (user.plan === "pro")</code> across the codebase. Put the rule in one function so changing it later is a one-file change.</p>
<p>Example code, not from a shipped product.</p>
<pre><code class="language-ts">async function hasAccess(db: D1Database, customerId: string, plan = "pro") {
  const row = await db
    .prepare("SELECT plan, status, current_until FROM entitlements WHERE customer_id = ?1")
    .bind(customerId)
    .first&lt;{ plan: string; status: string; current_until: string | null }&gt;();
  if (!row || row.plan !== plan) return false;
  if (row.status === "active") return true;
  // Decide on purpose what past_due means for you. Here: keep access until the paid period ends.
  return row.status === "past_due" &amp;&amp; !!row.current_until &amp;&amp; new Date(row.current_until) &gt; new Date();
}
</code></pre>
<p>The <code>past_due</code> line is a product decision, not a technical one. Write it down before launch, along with who can refund or comp a seat.</p>
<h2>5. Licence keys: an entitlement without a login</h2>
<p>If you sell to developers who install things from a CLI or an AI coding assistant, a full sign-up and login flow can be more than they want. A licence key in an environment variable fits the way they already work.</p>
<p>The key then becomes a lookup into the same entitlements data. Two rules keep it safe:</p>
<ul>
<li>Never store raw keys. Store a hash, so a database leak doesn't leak working keys.</li>
<li>Reject obvious junk early. An empty value, or a literal <code>${MY_LICENSE_KEY}</code> from an unset variable, should get a helpful message, not a confusing "invalid key".</li>
</ul>
<p>Example code, not from a shipped product.</p>
<pre><code class="language-ts">async function sha256Hex(s: string) {
  const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
  return [...new Uint8Array(buf)].map((b) =&gt; b.toString(16).padStart(2, "0")).join("");
}

async function customerForKey(db: D1Database, rawKey: string | null) {
  const key = rawKey?.trim();
  if (!key || key.startsWith("${")) return { error: "No licence key found. Check your environment variable." };
  const keyHash = await sha256Hex(key);
  const row = await db
    .prepare("SELECT customer_id FROM licence_keys WHERE key_hash = ?1")
    .bind(keyHash)
    .first&lt;{ customer_id: string }&gt;();
  return row ? { customerId: row.customer_id } : { error: "That licence key wasn't recognised." };
}
</code></pre>
<p>From there, <code>hasAccess()</code> does the rest. The webhook still decides who's paid, and the key is just a way of saying who's asking.</p>
<h2>Tradeoffs to decide on purpose</h2>
<table>
<thead>
<tr>
<th>Choice</th>
<th>What you give up</th>
<th>Why it can be worth it</th>
</tr>
</thead>
<tbody><tr>
<td>One entitlements table</td>
<td>Some duplication of what the provider knows</td>
<td>One place to check and debug access</td>
</tr>
<tr>
<td>Upserts, not increments</td>
<td>No running history on your side</td>
<td>Retried webhooks are harmless</td>
</tr>
<tr>
<td>Licence keys instead of accounts</td>
<td>No per-user dashboard of your own</td>
<td>No auth system to build for a developer tool</td>
</tr>
<tr>
<td>Grace for past_due</td>
<td>A lapsed customer may keep access a little longer</td>
<td>Paying customers aren't locked out by a failed card retry</td>
</tr>
</tbody></table>
<h2>Lessons</h2>
<ul>
<li>Billing is mostly about state, not checkout. The checkout page is the easy part.</li>
<li>Trust the webhook, verified. Don't unlock access from a client-side "success" redirect alone.</li>
<li>Write the edge cases down before launch. Failed payments, refunds and cancellations need an owner and a rule.</li>
<li>Buy the commodity parts. Payments and keys can come from a provider, which leaves more of an MVP budget for the product itself.</li>
</ul>
<h2>Where this fits in an MVP budget</h2>
<p>Billing and edge cases deserve their own slot in the schedule, not the last afternoon before launch. I wrote more about where SaaS MVP money actually goes in <a href="https://www.shaheermalik.com/blog/mvp-development-cost?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=w01-saas-mvp-cost&amp;utm_content=inline">this guide to SaaS MVP costs</a>. If you'd rather have it built, my <a href="https://www.shaheermalik.com/shipitlive/saas-mvp-in-30-days?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=w01-saas-mvp-cost&amp;utm_content=sprint">30-day SaaS MVP sprint</a> starts from $2,500 and includes billing and its edge cases.</p>
<p>Shaheer Malik designs and builds products at Ship It Live.</p>
]]></content:encoded></item></channel></rss>