<?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" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Node.js - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Node.js - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Fri, 14 Aug 2026 16:25:00 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/nodejs/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AI Agent with Per-User OAuth Access [Full Handbook] ]]>
                </title>
                <description>
                    <![CDATA[ When your AI agent serves more than one person, every tool call must answer: who's the agent acting for? Let's learn how to solve this by building an AI agent that connects with Slack and GitHub. A Sl ]]>
                </description>
                <link>https://www.freecodecamp.org/news/ai-agent-per-user-oauth-slack-github/</link>
                <guid isPermaLink="false">6a7c95758a35a7792fd567c3</guid>
                
                    <category>
                        <![CDATA[ ai agents ]]>
                    </category>
                
                    <category>
                        <![CDATA[ authentication ]]>
                    </category>
                
                    <category>
                        <![CDATA[ tool calling ]]>
                    </category>
                
                    <category>
                        <![CDATA[ oauth ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Saif Ali Shaik ]]>
                </dc:creator>
                <pubDate>Wed, 12 Aug 2026 15:47:01 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/889cf8c8-41f9-4dec-aa5f-128cb24f0082.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>When your AI agent serves more than one person, every tool call must answer: who's the agent acting for? Let's learn how to solve this by building an AI agent that connects with Slack and GitHub.</p>
<p>A Slack read uses that user's workspace. A GitHub issue is created as that user, in a repository they can access. An agent can make the wrong call, but it must never act with the wrong user's access.</p>
<p>The fix has two parts, and both appear in the first half of this tutorial:</p>
<ol>
<li><p><strong>Each user grants access separately.</strong> Alice authorizes Slack for herself. Bob authorizes it for himself.</p>
</li>
<li><p><strong>Your agent passes an identifier, not a token.</strong> A string like <code>alice@example.com</code> selects whose grant to use. One function turns it into a token at the moment of the call, and that token never reaches your model inputs, your tool schemas, or your logs.</p>
</li>
</ol>
<p>Most agent tutorials stop before either point. They hand you an API key, wire up one function, and the model calls it. The design works until a second person shows up.</p>
<p>To make the pattern concrete, you'll build a command-line agent that watches a Slack channel, decides on its own which messages describe real work, files a GitHub issue for those, and replies in the Slack thread with the issue link. Every call runs as one user's own OAuth grant.</p>
<p>You'll write the OAuth flow yourself: the consent redirect, the <code>state</code> check, the token exchange, an encrypted store, and the refresh path. None of it is long, and seeing it whole is what makes the identity argument checkable instead of a claim you take on faith.</p>
<p>Two topics stay out of scope here: we won't cover Model Context Protocol servers or voice or realtime hosts. The identity pattern holds in both settings, but the surrounding plumbing deserves its own article.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-are-ai-agent-tools">What Are AI Agent Tools?</a></p>
</li>
<li><p><a href="#heading-why-a-shared-token-breaks">Why a Shared Token Breaks</a></p>
</li>
<li><p><a href="#heading-architecture-overview">Architecture Overview</a></p>
</li>
<li><p><a href="#heading-how-to-register-the-slack-and-github-oauth-apps">How to Register the Slack and GitHub OAuth Apps</a></p>
</li>
<li><p><a href="#heading-how-to-run-the-consent-flow">How to Run the Consent Flow</a></p>
</li>
<li><p><a href="#heading-how-to-store-tokens-encrypted-keyed-by-user">How to Store Tokens Encrypted, Keyed by User</a></p>
</li>
<li><p><a href="#heading-how-to-run-tool-calls-as-the-current-user">How to Run Tool Calls as the Current User</a></p>
</li>
<li><p><a href="#heading-how-to-handle-refresh-and-revocation">How to Handle Refresh and Revocation</a></p>
</li>
<li><p><a href="#heading-how-to-add-a-second-provider">How to Add a Second Provider</a></p>
</li>
<li><p><a href="#heading-full-walkthrough">Full Walkthrough</a></p>
</li>
<li><p><a href="#heading-how-to-apply-the-pattern-to-other-use-cases">How to Apply the Pattern to Other Use Cases</a></p>
</li>
<li><p><a href="#heading-what-went-wrong-when-i-built-this">What Went Wrong When I Built This</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>The agent is called <code>channel-watcher-agent</code>. Each run does four things:</p>
<ol>
<li><p>Reads recent messages from a Slack channel.</p>
</li>
<li><p>Asks a model, message by message, whether the text describes a bug or a concrete action item.</p>
</li>
<li><p>Files a GitHub issue for the messages that qualify.</p>
</li>
<li><p>Replies in the original Slack thread with a link to the new issue.</p>
</li>
</ol>
<p><strong>Nobody clicks a button to start any of it.</strong> Slack already ships a "create an issue from this message" action, which is a different product. Here the agent reads the channel, forms its own judgment, and acts only on what it judges worth acting on.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5d426742d3ccd88c5676d4a2/cb98e59f-ac1a-48c7-9ab3-46a49f78af58.png" alt="Example of the tool in action" style="display:block;margin:0 auto" width="1014" height="1028" loading="lazy">

<p>The stack stays small on purpose:</p>
<table>
<thead>
<tr>
<th>Piece</th>
<th>Role</th>
</tr>
</thead>
<tbody><tr>
<td>Node.js, plain ES modules</td>
<td>No web framework, no queue</td>
</tr>
<tr>
<td><code>node:http</code></td>
<td>The OAuth callback server</td>
</tr>
<tr>
<td><code>node:crypto</code></td>
<td>Token encryption</td>
</tr>
<tr>
<td><code>node:sqlite</code></td>
<td>The token store, with no dependency to install</td>
</tr>
<tr>
<td><a href="https://ai-sdk.dev/">Vercel AI SDK</a></td>
<td>The model call and the tool loop</td>
</tr>
</tbody></table>
<p>Three of those five ship with Node. The only packages you install are the AI SDK and its friends.</p>
<p>By the end you'll have:</p>
<ul>
<li><p>Two OAuth apps, Slack and GitHub, that a user consents to once.</p>
</li>
<li><p>An encrypted token store keyed by user and provider.</p>
</li>
<li><p>An agent that resolves the current user to an identifier and never lets a token reach the model.</p>
</li>
<li><p>A tool loop where the model decides whether to file an issue at all.</p>
</li>
<li><p>A demonstration that a second user's run stops instead of reading the first user's data.</p>
</li>
</ul>
<p>The finished code lives at <a href="https://github.com/saif-shines/channel-watcher-agent">github.com/saif-shines/channel-watcher-agent</a>.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Accounts and tools:</p>
<ul>
<li><p><strong>Node.js 22.13 or newer</strong>, plus npm. The token store uses <a href="https://nodejs.org/api/sqlite.html"><code>node:sqlite</code></a>, which is stable from that version on.</p>
</li>
<li><p><strong>A Slack workspace</strong> where you can install apps, and a channel to watch. A throwaway channel works best.</p>
</li>
<li><p><strong>A GitHub account</strong> and a repository that can absorb test issues.</p>
</li>
<li><p><strong>An API key for a model provider</strong> the AI SDK supports. Anthropic is used in the examples.</p>
</li>
<li><p><a href="https://github.com/FiloSottile/mkcert"><strong>mkcert</strong></a>, to issue a local HTTPS certificate. <a href="#heading-how-to-register-the-slack-and-github-oauth-apps">How to Register the Slack and GitHub OAuth Apps</a> explains why an ordinary <code>http://localhost</code> callback will not do.</p>
</li>
</ul>
<p>Useful background, though none of it is a hard requirement:</p>
<ul>
<li><p><code>async</code> and <code>await</code>, and reading a small Node script.</p>
</li>
<li><p>OAuth 2.0 at a high level: an app redirects a user to a provider, the user consents, the app receives a token.</p>
</li>
<li><p>Tool calling, sometimes called function calling. The next section covers what the tutorial needs.</p>
</li>
</ul>
<p><strong>One warning before starting:</strong> The agent writes to real systems. It opens real GitHub issues and posts real Slack messages. Use a test Slack channel and a throwaway GitHub repository while you're still checking that it only acts on messages you intend.</p>
<h2 id="heading-what-are-ai-agent-tools">What Are AI Agent Tools?</h2>
<p><strong>A tool is a function you hand the model along with your input.</strong> The model can't run that function itself. It can only ask: call <code>fileGithubIssue</code> with this title and this body. Your code performs the call, returns the result, and the model uses that result to choose the next step.</p>
<p>Request, execute, return. The exchange is the whole mechanism, and everything called an "agent" is a loop around it.</p>
<h3 id="heading-how-a-tool-differs-from-an-api">How a Tool Differs from an API</h3>
<p>Tools and APIs wrap the same call but are written for different readers.</p>
<p>An API is written for you. It assumes you read the documentation, and that you know <code>thread_ts</code> is the field that turns a Slack message into a threaded reply.</p>
<p>A tool is written for a model that has read nothing. So a tool carries its own explanation:</p>
<ul>
<li><p>A <strong>name</strong> the model can reason about, like <code>fileGithubIssue</code>.</p>
</li>
<li><p>A <strong>description</strong> in plain language, including when not to use the tool.</p>
</li>
<li><p>A <strong>schema</strong> for the inputs, so the model knows <code>title</code> is a required string.</p>
</li>
</ul>
<p>Below is one tool from the project. Most of the code is explanation rather than logic:</p>
<pre><code class="language-javascript">const fileGithubIssue = tool({
  description: 'File a GitHub issue for an actionable Slack message',
  inputSchema: z.object({
    title: z.string(),
    body: z.string(),
  }),
  execute: async ({ title, body }) =&gt; {
    // ... the actual API call goes here
  },
});
</code></pre>
<p>The <code>description</code> and <code>inputSchema</code> are the parts the model sees. The <code>execute</code> function is yours alone. Identity gets settled inside <code>execute</code>, so the model never learns which account the call ran against.</p>
<h3 id="heading-why-models-handle-tools-better-than-raw-api-calls">Why Models Handle Tools Better Than Raw API Calls</h3>
<p>Pasting a curl command into the input and asking the model to fill in the blanks is possible. But this approach fails in predictable ways.</p>
<p>Tools work better for three reasons:</p>
<ol>
<li><p>The schema is enforced before your code runs. A malformed tool call gets rejected and retried by the SDK. A malformed URL fails at runtime instead.</p>
</li>
<li><p>Results return to the model. After <code>fileGithubIssue</code> returns, the model can read the new issue URL and use it in the Slack reply. The chaining is what makes the second step possible.</p>
</li>
<li><p>Credentials stay out of the conversation. The model asks for an action by name and never sees a token. A token it never sees can't leak into a completion, a log line, or a prompt-injection payload.</p>
</li>
</ol>
<p>Reason three is what the rest of this tutorial builds toward. You'll keep tokens out of the model on purpose: the agent holds an identifier, and a token appears only at the moment of the provider call.</p>
<h3 id="heading-most-agents-need-more-than-one-app">Most Agents Need More Than One App</h3>
<p>Few useful agents talk to a single app. A support agent reads Zendesk and updates Salesforce. A standup agent reads GitHub and posts to Slack. A scheduling agent reads Gmail and writes to Google Calendar.</p>
<p>Each app brings its own OAuth registration, scope names, token lifetime, and refresh behavior. Multiply the list by every user of the agent, and the real problem appears.</p>
<h2 id="heading-why-a-shared-token-breaks">Why a Shared Token Breaks</h2>
<p>One shared credential for everybody works in a demo and fails once a second person shows up. Picture the quick version of the Slack half: create a Slack app, install it, copy the bot token into <code>.env</code>, and let every tool call use it.</p>
<p>Three problems arrive together.</p>
<p>First, every run uses the same permissions. The bot sees every channel it was invited to, no matter who triggered the run. Ask the agent about a channel you were never in, and the bot reads it anyway. The agent has become a way around your own workspace permissions.</p>
<p>Second, the audit trail is also wrong. Every GitHub issue says the bot opened it. Every Slack reply comes from the bot. Asked why an issue exists, the honest answer is "an agent filed it for somebody, and we can't tell who."</p>
<p>And third, revocation stops working. A user leaves the company and their Slack account is deactivated. The agent keeps running, because it never used their credentials.</p>
<p>The alternative is per-user grants. Each user authorizes the apps for themselves. That creates a new requirement, though: somewhere to keep those grants.</p>
<h3 id="heading-the-distinction-is-one-field-in-one-response">The Distinction is One Field in One Response</h3>
<p>Slack makes the difference unusually easy to see. When a user finishes the consent screen, <a href="https://docs.slack.dev/authentication/installing-with-oauth">the token exchange</a> returns both kinds of token in the same JSON object:</p>
<pre><code class="language-json">{
  "ok": true,
  "access_token": "xoxb-REDACTED-BOT-TOKEN",
  "token_type": "bot",
  "authed_user": {
    "id": "U0A1B2C3D",
    "scope": "channels:history,chat:write,users:read",
    "access_token": "xoxp-REDACTED-USER-TOKEN",
    "token_type": "user"
  }
}
</code></pre>
<p>The top-level <code>access_token</code> is the bot. The nested <code>authed_user.access_token</code> is the person who just consented. Reading <code>conversations.history</code> with the first one returns every channel the app was invited to. Reading it with the second returns only the channels that users can already see. The same split governs writes: <a href="https://docs.slack.dev/reference/methods/chat.postMessage"><code>chat.postMessage</code></a> with a user token posts under that person's name.</p>
<p>Two fields, one letter apart in the prefix, and the entire permission model of your agent hangs on which one you store. This tutorial requests only user scopes, so Slack issues no bot token at all.</p>
<h3 id="heading-tokens-must-stay-out-of-the-model-and-the-logs">Tokens Must Stay Out of the Model and the Logs</h3>
<p>Per-user tokens become the most sensitive data in the system. Two destinations are off limits:</p>
<ul>
<li><p><strong>The model:</strong> Keep tokens out of inputs, tool descriptions, and tool return values. A model that has seen a token can repeat it, and prompt injection turns any tool result into untrusted input.</p>
</li>
<li><p><strong>Your logs:</strong> Tool inputs and outputs are exactly what you want to log while debugging an agent. Tokens traveling in those payloads land in your log store permanently.</p>
</li>
</ul>
<p>This tutorial keeps tokens on one narrow path. Your code passes an identifier, a stable reference to one user. One helper turns that identifier into a token, and from there the token goes straight into a provider call and nowhere else. It's never named in a tool schema, never attached to anything the model can read, and never returned from a tool.</p>
<h3 id="heading-why-you-own-the-oauth-apps-and-the-store">Why You Own the OAuth Apps and the Store</h3>
<p>The point of writing the flow yourself isn't the plumbing. It's control over who may use whose grant.</p>
<p>In this tutorial the users are teammates. Each person connects their own Slack and GitHub, and the agent acts as whoever triggered the run. The same design holds when those users are customers of your product: each person still has their own grant, and a wrong mapping means one person's run using someone else's access. Only the source of the identifier changes. A session for teammates, a tenant record for customers.</p>
<h2 id="heading-architecture-overview">Architecture Overview</h2>
<p>Two flows matter, and they happen at different times. Keeping them separate is most of the work.</p>
<p>Connection time happens once per user, per app. The user consents, and tokens land in your store. The agent isn't running.</p>
<p>Runtime happens on every execution. The agent resolves the current user to an identifier and does its work. No consent screens and no browser.</p>
<pre><code class="language-text">CONNECTION TIME (once per user, per app)

  Your user              connect.js              Slack / GitHub
     |                       |                         |
     |-- "connect Slack" ---&gt;|                         |
     |&lt;--- consent link -----|                         |
     |----------------------- OAuth consent ----------&gt;|
     |                       |&lt;--- redirect + code ----|
     |                       |---- exchange code -----&gt;|
     |                       |&lt;---- tokens ------------|
     |                       |                         |
     |                  [encrypt, store                |
     |                   under (identifier,            |
     |                   provider)]                    |
     |                       |                         |


RUNTIME (every agent run)

  Your agent             Token store             Slack / GitHub
     |                       |                         |
  [resolve identifier        |                         |
   from your own session]    |                         |
     |                       |                         |
     |-- getAccessToken( ---&gt;|                         |
     |     identifier,       |                         |
     |     provider )        |                         |
     |&lt;---- token -----------|                         |
     |                       |                         |
     |------------------ API call as user ------------&gt;|
     |&lt;----------------- result -----------------------|
     |                       |                         |
  [model sees result,        |                         |
   never a token]            |                         |
</code></pre>
<p>Three properties follow from the shape.</p>
<p>The identifier replaces the token in your agent code. Everything above the token store handles a string like <code>alice@example.com</code> or <code>user_8f21c</code>. The string is worthless on its own: without the store and its encryption key, it opens nothing.</p>
<p>One identity spans many apps. A single identifier has a Slack row and a GitHub row beneath it. A third app doesn't create a third identity to reconcile.</p>
<p>Authorization stays in your code. The store answers which tokens belong to an identifier. The store can't know whether the request deserved an answer. Deciding that the caller may act as that identifier happens before any call.</p>
<p>One rule follows, and bending it defeats the whole design: resolve the identifier server-side from an authenticated session. Never accept an identifier from a request body, a query parameter, or a browser. An identifier accepted from a client is an "act as any user" endpoint.</p>
<h2 id="heading-how-to-register-the-slack-and-github-oauth-apps">How to Register the Slack and GitHub OAuth Apps</h2>
<p>The walkthrough uses Slack and GitHub as the two providers end to end. Both need the same three things: a registered app, a redirect URI, and a set of scopes. The details differ enough to be worth walking through separately.</p>
<h3 id="heading-the-redirect-uri-has-to-use-https">The Redirect URI Has to Use HTTPS</h3>
<p>Most tutorials that touch OAuth hand you <code>http://localhost:3000/callback</code> and move on. Slack rejects it. <a href="https://docs.slack.dev/authentication/installing-with-oauth">Slack's documentation</a> states flatly that "a Redirect URL must also use HTTPS", and it makes no exception for <code>localhost</code>. GitHub is more relaxed and accepts either, so a single HTTPS callback satisfies both.</p>
<p>The rule looks pedantic, because on <code>localhost</code> the request never leaves your machine and there's nothing on the wire to intercept. Slack applies it uniformly anyway, and a uniform rule with no exemptions is a defensible choice for a provider handing out credentials: every exemption is a branch somebody has to get right, and "is this really localhost" is a question that has been answered incorrectly before.</p>
<p><a href="https://github.com/FiloSottile/mkcert">mkcert</a> issues a certificate signed by a local authority it adds to your system trust store, so the browser accepts it without a warning:</p>
<pre><code class="language-bash">mkcert -install
mkcert localhost
</code></pre>
<p>That writes <code>localhost.pem</code> and <code>localhost-key.pem</code> into the current directory. A tunneling service such as ngrok also works, but its free URLs rotate, which means re-editing both app registrations every session.</p>
<h3 id="heading-the-slack-app-and-the-one-setting-that-matters">The Slack App, and the One Setting That Matters</h3>
<p>At <a href="https://api.slack.com/apps">api.slack.com/apps</a>, create an app in your workspace. Then open <strong>OAuth &amp; Permissions</strong> and set two things.</p>
<p>Add <code>https://localhost:3000/callback</code> under <strong>Redirect URLs</strong>.</p>
<p>Then find the scopes. The page has two sections, and choosing the wrong one silently rebuilds the shared-bot design:</p>
<table>
<thead>
<tr>
<th>Section</th>
<th>What it grants</th>
<th>Use it here?</th>
</tr>
</thead>
<tbody><tr>
<td>Bot Token Scopes</td>
<td>A <code>xoxb-</code> token that acts as the app</td>
<td>No</td>
</tr>
<tr>
<td>User Token Scopes</td>
<td>A <code>xoxp-</code> token that acts as the person</td>
<td>Yes</td>
</tr>
</tbody></table>
<p>Under <strong>User Token Scopes</strong>, add:</p>
<ul>
<li><p><code>channels:history</code>: read messages in public channels the user belongs to</p>
</li>
<li><p><code>chat:write</code>: post as the user</p>
</li>
<li><p><code>users:read</code>: turn user IDs into names</p>
</li>
</ul>
<p>Leave Bot Token Scopes empty. Copy the Client ID and Client Secret from <strong>Basic Information</strong>.</p>
<h3 id="heading-the-github-oauth-app">The GitHub OAuth App</h3>
<p>Under Settings → Developer settings → OAuth Apps → New OAuth App, set the Authorization callback URL to the same <code>https://localhost:3000/callback</code>, then generate a client secret. GitHub documents <a href="https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps">the web application flow</a> in full if you want the surrounding detail.</p>
<p>GitHub's <a href="https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps">scope</a> for issue creation depends on the repository:</p>
<ul>
<li><p><code>repo</code> covers private repositories, and grants read and write access to code along with it.</p>
</li>
<li><p><code>public_repo</code> is the narrower choice, and enough when your test repository is public.</p>
</li>
</ul>
<p>Take the narrower one when you can. A scope you didn't need is a scope you have to explain later.</p>
<h3 id="heading-the-environment-file">The Environment File</h3>
<p>Both apps produce a client ID and a client secret, and the store needs an encryption key. Generate the key first:</p>
<pre><code class="language-bash">node -e "console.log(require('node:crypto').randomBytes(32).toString('base64'))"
</code></pre>
<p>Then fill in <code>.env</code>:</p>
<pre><code class="language-bash">OAUTH_REDIRECT_URI=https://localhost:3000/callback
TLS_CERT_PATH=./localhost.pem
TLS_KEY_PATH=./localhost-key.pem

SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=

TOKEN_ENCRYPTION_KEY=

SLACK_CHANNEL_ID=C0XXXXXXXXX
GITHUB_REPO=your-name/your-test-repo
</code></pre>
<p>Those client secrets authenticate <strong>your application</strong> to the providers. They're not user credentials, and they never belong in a browser.</p>
<h2 id="heading-how-to-run-the-consent-flow">How to Run the Consent Flow</h2>
<p>Everything provider-specific belongs in one place, so that adding a third provider later means adding an entry rather than a branch.</p>
<h3 id="heading-step-1-describe-each-provider-once">Step 1: Describe Each Provider Once</h3>
<pre><code class="language-javascript">const REDIRECT_URI = process.env.OAUTH_REDIRECT_URI;

export const providers = {
  slack: {
    label: 'Slack',
    authorizeUrl: 'https://slack.com/oauth/v2/authorize',
    tokenUrl: 'https://slack.com/api/oauth.v2.access',

    // These go in `user_scope`, not `scope`. Scopes listed under `scope` grant
    // a bot token, and a bot token is what this project exists to avoid.
    userScopes: ['channels:history', 'chat:write', 'users:read'],

    buildAuthorizeUrl(state) {
      const url = new URL(this.authorizeUrl);
      url.searchParams.set('client_id', process.env.SLACK_CLIENT_ID);
      url.searchParams.set('user_scope', this.userScopes.join(','));
      url.searchParams.set('redirect_uri', REDIRECT_URI);
      url.searchParams.set('state', state);
      return url.toString();
    },
    // exchangeCode and refresh follow below
  },
};
</code></pre>
<p><strong>The</strong> <code>user_scope</code> <strong>parameter is the whole argument in one line.</strong> Slack reads <code>scope</code> for bot permissions and <code>user_scope</code> for user permissions. This project sets only the second, so the response comes back with no bot token in it at all.</p>
<p>The <code>state</code> parameter isn't optional. It's a random string you generate, send to the provider, and check on the way back. Without it, any page on the internet can point a browser at your callback URL with an attacker's <code>code</code> attached, and your server will happily exchange it and store the attacker's token under your user's identifier.</p>
<h3 id="heading-step-2-exchange-the-code-and-take-the-right-token">Step 2: Exchange the Code, and Take the Right Token</h3>
<pre><code class="language-javascript">async exchangeCode(code) {
  const response = await fetch(this.tokenUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      code,
      client_id: process.env.SLACK_CLIENT_ID,
      client_secret: process.env.SLACK_CLIENT_SECRET,
      redirect_uri: REDIRECT_URI,
    }),
  });

  const json = await response.json();

  // Slack answers HTTP 200 even when the exchange failed. The `ok` field
  // is the real status.
  if (!json.ok) {
    throw new Error(`Slack token exchange failed: ${json.error}`);
  }

  return normalizeSlackTokens(json.authed_user);
}
</code></pre>
<p>Two details in that function cost real debugging time when missed.</p>
<p><strong>Slack returns HTTP 200 for failures.</strong> Checking <code>response.ok</code> tells you the HTTP request succeeded, which it did. The <code>json.ok</code> field is the one that reports whether the OAuth exchange worked.</p>
<p><code>json.authed_user</code><strong>, not</strong> <code>json</code><strong>.</strong> This is the fork from the section above, expressed as one property access. Reading <code>json.access_token</code> here would compile, run, store a token, and quietly give every user of your agent the same bot identity.</p>
<p>Normalizing the result keeps the rest of the codebase provider-agnostic:</p>
<pre><code class="language-javascript">function normalizeSlackTokens(authedUser) {
  return {
    accessToken: authedUser.access_token,
    refreshToken: authedUser.refresh_token ?? null,
    expiresAt: authedUser.expires_in
      ? Date.now() + authedUser.expires_in * 1000
      : null,
    scope: authedUser.scope,
  };
}
</code></pre>
<p>GitHub's version of the same function differs in two ways worth noting:</p>
<pre><code class="language-javascript">async exchangeCode(code) {
  const response = await fetch(this.tokenUrl, {
    method: 'POST',
    // Without this header GitHub answers with a form-encoded body.
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Accept: 'application/json',
    },
    body: new URLSearchParams({
      code,
      client_id: process.env.GITHUB_CLIENT_ID,
      client_secret: process.env.GITHUB_CLIENT_SECRET,
      redirect_uri: REDIRECT_URI,
    }),
  });

  const json = await response.json();
  if (json.error) {
    throw new Error(
      `GitHub token exchange failed: ${json.error_description ?? json.error}`
    );
  }

  // OAuth App tokens carry no expiry, so there is nothing to refresh.
  return {
    accessToken: json.access_token,
    refreshToken: null,
    expiresAt: null,
    scope: json.scope,
  };
}
</code></pre>
<p>The <code>Accept: application/json</code> header is easy to skip and produces a confusing failure: <code>response.json()</code> throws on a body that came back as <code>access_token=gho_...&amp;scope=repo</code>.</p>
<h3 id="heading-step-3-catch-the-redirect">Step 3: Catch the Redirect</h3>
<p>OAuth needs somewhere to land. For a command-line tool, a server that starts, handles one callback per provider, and exits is enough. Because Slack demands HTTPS, the scheme in <code>OAUTH_REDIRECT_URI</code> decides which kind of server to start:</p>
<pre><code class="language-javascript">function createCallbackServer(handler) {
  if (redirect.protocol !== 'https:') {
    return createHttpServer(handler);
  }

  try {
    return createHttpsServer(
      {
        cert: readFileSync(process.env.TLS_CERT_PATH),
        key: readFileSync(process.env.TLS_KEY_PATH),
      },
      handler
    );
  } catch (err) {
    throw new Error(
      `Could not read the TLS certificate (${err.code ?? err.message}).\n` +
        'Generate a locally-trusted one with mkcert:\n' +
        '  mkcert -install\n' +
        '  mkcert localhost\n' +
        'then point TLS_CERT_PATH and TLS_KEY_PATH at the two files it writes.'
    );
  }
}
</code></pre>
<p>A missing certificate is going to happen to somebody, and <code>ENOENT</code> on its own explains nothing about OAuth. The catch block spends four lines saying what to run instead.</p>
<p>The handler itself is where <code>state</code> gets checked:</p>
<pre><code class="language-javascript">const pending = new Map();

function handleCallback(request, response) {
  const url = new URL(request.url, redirect.origin);

  if (url.pathname !== redirect.pathname) {
    response.writeHead(404).end('Not found');
    return;
  }

  const state = url.searchParams.get('state');
  const entry = pending.get(state);

  if (!entry) {
    response.writeHead(400).end('State mismatch. Start the flow again.');
    return;
  }

  pending.delete(state);

  const error = url.searchParams.get('error');
  if (error) {
    response.writeHead(400).end(`Authorization denied: ${error}`);
    entry.reject(new Error(`[${entry.provider}] authorization denied: ${error}`));
    return;
  }

  entry.finish(url.searchParams.get('code'), response);
}
</code></pre>
<p><strong>The</strong> <code>pending</code> <strong>map is the</strong> <code>state</code> <strong>check.</strong> A state value gets into that map only when this process generated it, and it's deleted the moment it's used. An unrecognized state means the callback didn't come from a flow you started, and a state that arrives twice means a replay. Both fall out of one <code>Map</code> lookup.</p>
<p>Generating the state and waiting for its callback:</p>
<pre><code class="language-javascript">function connect(providerName) {
  const provider = providers[providerName];
  const state = randomBytes(16).toString('hex');

  console.log(`\n[${providerName}] authorize as "${IDENTIFIER}":`);
  console.log(provider.buildAuthorizeUrl(state));

  return new Promise((resolve, reject) =&gt; {
    pending.set(state, {
      provider: providerName,
      reject,
      async finish(code, response) {
        const tokens = await provider.exchangeCode(code);
        saveGrant(IDENTIFIER, providerName, tokens);
        response
          .writeHead(200, { 'Content-Type': 'text/html' })
          .end(`&lt;p&gt;${provider.label} connected. You can close this tab.&lt;/p&gt;`);
        resolve();
      },
    });
  });
}
</code></pre>
<p><code>randomBytes(16)</code> and not <code>Math.random()</code>. A predictable state parameter is the same as no state parameter.</p>
<p>Running it walks each unconnected provider in turn:</p>
<pre><code class="language-text">[slack] authorize as "alice@example.com":
https://slack.com/oauth/v2/authorize?client_id=123.456&amp;user_scope=channels%3Ahistory%2Cchat%3Awrite%2Cusers%3Aread&amp;redirect_uri=https%3A%2F%2Flocalhost%3A3000%2Fcallback&amp;state=1159699dbf1a808fd33ba31c7b643505
</code></pre>
<p>Notice what that URL doesn't contain: any <code>scope</code> parameter. Slack has no instruction to mint a bot token, so it won't.</p>
<h2 id="heading-how-to-store-tokens-encrypted-keyed-by-user">How to Store Tokens Encrypted, Keyed by User</h2>
<p>The store answers one question: which token belongs to this user, for this provider? Everything else about it follows from keeping that answer safe.</p>
<p><code>node:sqlite</code> has shipped with Node since v22.5, and stopped requiring a flag in v22.13. That makes a real database available with nothing to install:</p>
<pre><code class="language-javascript">import { DatabaseSync } from 'node:sqlite';
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';

const KEY = Buffer.from(process.env.TOKEN_ENCRYPTION_KEY ?? '', 'base64');

if (KEY.length !== 32) {
  throw new Error(
    'TOKEN_ENCRYPTION_KEY must be 32 bytes, base64-encoded. ' +
      `Got ${KEY.length} bytes.`
  );
}

const db = new DatabaseSync(
  process.env.TOKEN_DB_PATH ?? new URL('../tokens.db', import.meta.url).pathname
);

// One row per user, per provider. expires_at stays outside the ciphertext so
// a token's freshness can be checked without decrypting it.
db.exec(`
  CREATE TABLE IF NOT EXISTS grants (
    identifier TEXT    NOT NULL,
    provider   TEXT    NOT NULL,
    ciphertext BLOB    NOT NULL,
    iv         BLOB    NOT NULL,
    auth_tag   BLOB    NOT NULL,
    expires_at INTEGER,
    PRIMARY KEY (identifier, provider)
  )
`);
</code></pre>
<p><strong>The composite primary key is the isolation guarantee, written down.</strong> <code>(identifier, provider)</code> means Alice's Slack row and Bob's Slack row can't collide, and no query that supplies both parts can return somebody else's grant.</p>
<p><code>expires_at</code> <strong>sits outside the ciphertext deliberately.</strong> Checking whether a token needs refreshing is something you do before every call. Decrypting to find out would mean decrypting constantly, so the one field that isn't a secret stays readable.</p>
<p>Encryption is AES-256-GCM, which authenticates as well as encrypts:</p>
<pre><code class="language-javascript">function encrypt(payload) {
  const iv = randomBytes(12);
  const cipher = createCipheriv('aes-256-gcm', KEY, iv);
  const ciphertext = Buffer.concat([
    cipher.update(JSON.stringify(payload), 'utf8'),
    cipher.final(),
  ]);
  return { ciphertext, iv, authTag: cipher.getAuthTag() };
}

function decrypt({ ciphertext, iv, authTag }) {
  const decipher = createDecipheriv('aes-256-gcm', KEY, iv);
  decipher.setAuthTag(authTag);
  const plaintext = Buffer.concat([
    decipher.update(ciphertext),
    decipher.final(),
  ]);
  return JSON.parse(plaintext.toString('utf8'));
}
</code></pre>
<p>Three rules govern that pair, and breaking any one of them is worse than not encrypting at all, because it looks like it worked:</p>
<ol>
<li><p><strong>A fresh IV per encryption:</strong> Reusing an initialization vector with GCM is a catastrophic failure, not a minor one. <code>randomBytes(12)</code> on every call, stored beside the ciphertext.</p>
</li>
<li><p><strong>Keep the auth tag:</strong> GCM produces a tag that proves the ciphertext wasn't altered. Without <code>setAuthTag</code> on the way back, you have encryption without integrity, and <code>decipher.final()</code> won't complain.</p>
</li>
<li><p><strong>Encrypt the whole token object, not each field.</strong> One ciphertext for <code>{ accessToken, refreshToken, scope }</code> means one IV and one tag to manage rather than three of each.</p>
</li>
</ol>
<p>Writing and reading are then unremarkable:</p>
<pre><code class="language-javascript">export function saveGrant(identifier, provider, tokens) {
  const { ciphertext, iv, authTag } = encrypt(tokens);
  db.prepare(
    `INSERT INTO grants (identifier, provider, ciphertext, iv, auth_tag, expires_at)
     VALUES (?, ?, ?, ?, ?, ?)
     ON CONFLICT (identifier, provider) DO UPDATE SET
       ciphertext = excluded.ciphertext,
       iv         = excluded.iv,
       auth_tag   = excluded.auth_tag,
       expires_at = excluded.expires_at`
  ).run(identifier, provider, ciphertext, iv, authTag, tokens.expiresAt ?? null);
}
</code></pre>
<p>The <code>ON CONFLICT</code> clause matters more than it looks. Re-consenting has to replace a grant rather than fail or duplicate it, and re-consenting is exactly what a user does after a revocation or a scope change.</p>
<p>The encryption key itself lives in <code>.env</code> here, which is right for a tutorial and wrong for production, where it belongs in a secrets manager or a KMS. Losing it makes every stored grant unreadable and forces every user to consent again. That is a real outage, but it's a better one than the alternative: a stolen database file that hands over working tokens for every user of your agent.</p>
<h2 id="heading-how-to-run-tool-calls-as-the-current-user">How to Run Tool Calls as the Current User</h2>
<p>Runtime has three moves: resolve the identifier, fetch a token with it, and wrap the whole thing as a tool.</p>
<h3 id="heading-step-1-resolve-the-identifier-then-authorize">Step 1: Resolve the Identifier, Then Authorize</h3>
<p>An identifier is <strong>any stable string</strong> that represents one user, an email address, a user ID, a tenant-scoped key.</p>
<pre><code class="language-javascript">// In a real app this comes from your authenticated session, resolved
// server-side. Never accept it from client input.
const IDENTIFIER = process.argv[2] ?? 'channel-watcher-agent';
</code></pre>
<p>Reading the identifier from <code>argv</code> keeps the demo runnable without a login, and it makes the isolation test later in this tutorial a single command. A real application replaces the line:</p>
<pre><code class="language-javascript">// Real app: resolve from your authenticated session, server-side.
const session = await getSession(request);                  // your auth
const identifier = await lookupIdentifier(session.userId);  // your database
</code></pre>
<p><strong>Order matters in those two lines.</strong> Authenticate the caller first, then look up which identifier the caller may act as. An identifier arriving from a client turns the endpoint into a reader of any user's Slack.</p>
<h3 id="heading-step-2-turn-the-identifier-into-a-token-late">Step 2: Turn the Identifier into a Token, Late</h3>
<p>One function stands between the identifier and every provider call:</p>
<pre><code class="language-javascript">const REFRESH_WINDOW_MS = 60_000;

export async function getAccessToken(identifier, providerName) {
  const grant = readGrant(identifier, providerName);

  if (!grant) {
    throw new Error(
      `[${providerName}] no grant for "${identifier}".\n` +
        `Connect it first: node src/connect.js ${identifier}`
    );
  }

  const expiringSoon =
    grant.expiresAt !== null &amp;&amp;
    grant.expiresAt !== undefined &amp;&amp;
    grant.expiresAt - Date.now() &lt; REFRESH_WINDOW_MS;

  if (!expiringSoon) {
    return grant.accessToken;
  }

  if (!grant.refreshToken) {
    throw new Error(
      `[${providerName}] token for "${identifier}" expired and no refresh ` +
        'token is stored. The user has to consent again.'
    );
  }

  const refreshed = await providers[providerName].refresh(grant.refreshToken);
  saveGrant(identifier, providerName, refreshed);
  return refreshed.accessToken;
}
</code></pre>
<p><strong>Call this immediately before the API call, not once at startup.</strong> A long agent run can outlive a twelve-hour token, and resolving tokens up front means discovering that at the least convenient moment. Fetching late costs one cheap database read and removes the whole class of problem.</p>
<p>Also, the <strong>sixty-second window isn't padding for its own sake.</strong> A token with four seconds left passes a naive expiry check and then expires in flight. Refreshing anything inside the window means the token handed back is good for at least a minute of work.</p>
<p>Finally, a missing grant raises an error rather than falling back. There's nothing sensible to fall back to. The correct outcome for an unconnected user is a stop, with a message saying how to connect.</p>
<h3 id="heading-step-3-wrap-provider-calls-as-tools">Step 3: Wrap Provider Calls as Tools</h3>
<p>Identity gets injected here, one layer below anything the model can influence:</p>
<pre><code class="language-javascript">export function buildTools(identifier) {
  const [owner, repo] = process.env.GITHUB_REPO.split('/');

  const fileGithubIssue = tool({
    description: 'File a GitHub issue for an actionable Slack message',
    inputSchema: z.object({
      title: z.string(),
      body: z.string(),
    }),
    execute: async ({ title, body }) =&gt; {
      const token = await getAccessToken(identifier, 'github');
      return createIssue(token, owner, repo, { title, body });
    },
  });

  const replyInSlackThread = tool({
    description:
      'Reply in the original Slack thread (e.g. with the created issue link)',
    inputSchema: z.object({
      text: z.string(),
      thread_ts: z.string(),
    }),
    execute: async ({ text, thread_ts }) =&gt; {
      const token = await getAccessToken(identifier, 'slack');
      return postThreadReply(
        token,
        process.env.SLACK_CHANNEL_ID,
        text,
        thread_ts
      );
    },
  });

  return { fileGithubIssue, replyInSlackThread };
}
</code></pre>
<p>Compare what the model controls against what it can't. The model chooses <code>title</code>, <code>body</code>, and <code>text</code>. <strong>The model can't choose the user.</strong> <code>identifier</code> is a closure argument, fixed before the model ran, and it appears in no <code>inputSchema</code>. There's no input that makes the model file an issue as somebody else, because the account isn't one of its inputs.</p>
<p>Return values deserve one audit each. <code>createIssue</code> returns the issue number, URL, and title. <code>postThreadReply</code> returns a timestamp. Neither returns a token, and neither returns the raw provider response, which is where a token would hide if one were going to.</p>
<p>The provider calls themselves are ordinary HTTP:</p>
<pre><code class="language-javascript">export async function createIssue(token, owner, repo, { title, body }) {
  const response = await fetch(
    `https://api.github.com/repos/${owner}/${repo}/issues`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: 'application/vnd.github+json',
        'X-GitHub-Api-Version': '2022-11-28',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ title, body }),
    }
  );

  const json = await response.json();

  if (!response.ok) {
    // 403 here usually means the grant is missing the `repo` scope.
    throw new Error(
      `GitHub issue creation failed (${response.status}): ${json.message}`
    );
  }

  return { number: json.number, url: json.html_url, title: json.title };
}
</code></pre>
<h3 id="heading-step-4-read-the-channel">Step 4: Read the Channel</h3>
<p>Slack's <a href="https://docs.slack.dev/reference/methods/conversations.history"><code>conversations.history</code></a> returns clean JSON, with one gap: messages carry a user ID, never a display name. Turning those into names means a <a href="https://docs.slack.dev/reference/methods/users.info"><code>users.info</code></a> call each, which is what <code>users:read</code> was in the scope list for.</p>
<pre><code class="language-javascript">export async function readChannel(token, channelId, limit = 20) {
  const { messages } = await slackCall(token, 'conversations.history', {
    channel: channelId,
    limit: String(limit),
  });

  const authors = await resolveAuthors(
    token,
    messages.filter((m) =&gt; m.user).map((m) =&gt; m.user)
  );

  return messages
    .filter((message) =&gt; message.text)
    .map((message) =&gt; ({
      author: authors.get(message.user) ?? 'unknown',
      userId: message.user,
      text: message.text,
      ts: message.ts,
    }))
    .reverse(); // oldest first
}
</code></pre>
<p>Three small decisions in that function:</p>
<ol>
<li><p><strong>Names cost one</strong> <code>users.info</code> <strong>call per unique author.</strong> Caching them per run keeps a channel full of one person's messages from producing twenty identical lookups. A lookup that fails falls back to the user ID rather than throwing, since an unresolvable name isn't a reason to abandon the run.</p>
</li>
<li><p><strong>Messages without</strong> <code>text</code> <strong>get dropped.</strong> Channel joins and purpose changes arrive as message objects with no body, and there's nothing for the model to triage in them.</p>
</li>
<li><p><code>.reverse()</code> <strong>isn't cosmetic.</strong> Slack returns newest first. A model reading a conversation backwards will misread which message answered which.</p>
</li>
</ol>
<p>The <code>ts</code> field then does double duty. It identifies a message, which makes it both the thread anchor for replies and the key for remembering what the agent already handled:</p>
<pre><code class="language-javascript">const state = await loadState();
const processed = new Set(state[IDENTIFIER]?.processedTs ?? []);
const newMessages = messages.filter((m) =&gt; !processed.has(m.ts));
</code></pre>
<p><strong>Key that state by identifier</strong>, as the snippet does. A single flat list lets one user's processed messages hide another's, which reintroduces cross-user bleed in the one place the whole design exists to prevent.</p>
<h3 id="heading-step-5-run-the-tool-loop">Step 5: Run the Tool Loop</h3>
<p>Hand the model both tools and let it decide:</p>
<pre><code class="language-javascript">const { text } = await generateText({
  model: anthropic(process.env.MODEL),
  tools,
  stopWhen: stepCountIs(5),
  prompt: `You triage messages from a dev team's Slack channel.

Message from ${message.author}: "${message.text}"
Message timestamp (thread_ts): ${message.ts}

Decide if this message is actionable (a bug report or concrete action item) or just noise (chit-chat, join notices, already-resolved chatter).

If actionable: file a GitHub issue with a clear title and body drafted from the message, then reply in the original Slack thread (use the exact thread_ts above) with a short note and the created issue's URL.

If not actionable: do nothing and briefly say why.`,
});
</code></pre>
<p>The <strong>loop</strong> is what makes the second step possible. The model reads the message and may call <code>fileGithubIssue</code>. The AI SDK runs the tool, feeds the result back into context along with the new issue URL, and calls the model again. Now the model can reply in the thread with a URL it couldn't have known on the first pass. Then it stops.</p>
<p><code>stopWhen: stepCountIs(5)</code> caps the rounds. Without a bound, a confused model can retry a failing tool indefinitely. Five rounds is generous for two tools.</p>
<p>A deterministic version is also reasonable: classify with a structured-output call, then call both tools yourself in a fixed order when the message qualifies.</p>
<p>The fixed sequence is easier to test and gives up real flexibility. A loop lets the model skip the reply, or file without replying, and adding a third tool needs no new branching. Choose the loop when the set of actions varies per input, and the fixed sequence when it never does.</p>
<p>One note on the provider line, for accuracy about what ran. The snippet above uses <code>@ai-sdk/anthropic</code>, which suits a direct Anthropic API key. My own tests went through an OpenAI-compatible gateway, which changes only the provider construction:</p>
<pre><code class="language-javascript">import { createOpenAICompatible } from '@ai-sdk/openai-compatible';

const gateway = createOpenAICompatible({
  name: 'gateway',
  baseURL: `${process.env.GATEWAY_BASE_URL}/v1`,
  apiKey: process.env.GATEWAY_API_KEY,
});
// then: model: gateway(process.env.MODEL)
</code></pre>
<p>The tools, the loop, and the token handling are identical either way. Only the <code>model</code> argument changes.</p>
<h2 id="heading-how-to-handle-refresh-and-revocation">How to Handle Refresh and Revocation</h2>
<p><strong>Tokens end in two different ways,</strong> and only one of them is your code's problem**.** Expiry is routine and recoverable. Revocation is a decision somebody made, and the correct response is to ask for consent again.</p>
<p>The two providers in this tutorial sit at opposite ends of the range, which makes them a useful pair.</p>
<h3 id="heading-github-tokens-that-dont-expire-until-they-do">GitHub: Tokens That Don't Expire, Until They Do</h3>
<p>An OAuth App user token has no expiry timestamp. There's no refresh token to store and no refresh call to make, which is why <code>github.refresh()</code> in this project does nothing but explain itself:</p>
<pre><code class="language-javascript">async refresh() {
  throw new Error(
    'GitHub OAuth App tokens do not expire. A failure here means the ' +
      'grant was revoked — send the user through consent again.'
  );
}
</code></pre>
<p>"Does not expire" is not the same as "lasts forever," and GitHub <a href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/token-expiration-and-revocation">revokes tokens</a> for several reasons worth knowing:</p>
<ul>
<li><p>The user revokes the authorization from their account settings.</p>
</li>
<li><p>The token goes unused for one year.</p>
</li>
<li><p>The token gets pushed to a public repository or gist, at which point GitHub revokes it automatically.</p>
</li>
<li><p>The app accumulates more than ten tokens for the same user and scope combination, and the oldest are revoked.</p>
</li>
</ul>
<p>The third one deserves a moment. GitHub scans public pushes for its own token formats and kills what it finds. That is a safety net, not a strategy, and the one thing it can't protect is a token in a private repository or a log file.</p>
<p><strong>GitHub Apps behave differently from OAuth Apps</strong>, which is a common source of confusion when reading GitHub's documentation. A GitHub App's user access token expires in eight hours and comes with a refresh token good for six months. If you build on GitHub Apps instead, the Slack-shaped refresh path below is the one you want.</p>
<h3 id="heading-slack-rotation-is-opt-in-and-permanent">Slack: Rotation is Opt-in and Permanent</h3>
<p>By default, a Slack user token doesn't expire either. <a href="https://docs.slack.dev/authentication/using-token-rotation">Token rotation</a> changes that, and it comes with a warning worth repeating: <strong>rotation can't be turned off once it's turned on.</strong> Enable it on a test app first.</p>
<p>With rotation on, tokens live twelve hours and arrive with a refresh token. The refresh call reuses the same endpoint as the initial exchange, with a different grant type:</p>
<pre><code class="language-javascript">async refresh(refreshToken) {
  const response = await fetch(this.tokenUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: process.env.SLACK_CLIENT_ID,
      client_secret: process.env.SLACK_CLIENT_SECRET,
    }),
  });

  const json = await response.json();
  if (!json.ok) {
    throw new Error(`Slack token refresh failed: ${json.error}`);
  }

  return normalizeSlackTokens(json.authed_user ?? json);
}
</code></pre>
<p><strong>Store the new refresh token, not just the new access token.</strong> Refresh tokens rotate too. Writing back only the access token leaves you holding a spent refresh token, and the failure arrives twelve hours later, which is a long time to wait to learn something.</p>
<p>That write-back is why <code>getAccessToken</code> calls <code>saveGrant</code> after refreshing rather than returning the token and moving on.</p>
<h3 id="heading-treat-a-dead-grant-as-a-normal-state">Treat a Dead Grant as a Normal State</h3>
<p>A revoked grant isn't an exception in the exceptional sense. Users leave, administrators tighten scopes, and people change their minds about what an agent may do.</p>
<p>The shape that works is the one <code>getAccessToken</code> already uses: catch the failure, and surface a fresh authorization link rather than a stack trace. <code>connect.js</code> with the same identifier lets the user re-consent, <code>ON CONFLICT</code> overwrites the dead row, and nothing else in your user record changes.</p>
<h2 id="heading-how-to-add-a-second-provider">How to Add a Second Provider</h2>
<p><strong>A second provider costs one OAuth app, one entry in the providers object, and one tool.</strong> Keeping identity in a single string is what buys the discount.</p>
<p>The agent has used two providers all along. Worth noticing is what the second one didn't require: no second identity, no second consent server, and no second token table.</p>
<pre><code class="language-javascript">export const providers = {
  slack: { /* ... */ },
  github: { /* ... */ },
};
</code></pre>
<p>Google Calendar as a third means a third entry with its own <code>authorizeUrl</code>, <code>tokenUrl</code>, scopes, and <code>exchangeCode</code>. The consent server loops over <code>Object.keys(providers)</code>, so it picks the new one up without modification. The store already keys on <code>(identifier, provider)</code>, so it needs no migration. Then one more tool:</p>
<pre><code class="language-javascript">const createCalendarEvent = tool({
  description: 'Create a calendar event',
  inputSchema: z.object({ summary: z.string(), start: z.string() }),
  execute: async ({ summary, start }) =&gt; {
    const token = await getAccessToken(identifier, 'google-calendar');
    // ...one more provider call
  },
});
</code></pre>
<p>The identifier doesn't change, your user table doesn't change, and the model's view of the world grows by exactly one tool.</p>
<p>The cost that doesn't scale down is the <strong>provider-specific knowledge.</strong> Each new provider brings its own scope vocabulary, its own error format, and its own answer to whether tokens expire. Slack and GitHub disagreed on all three, and a third will disagree differently. The registry pattern contains that knowledge in one object per provider rather than spreading it through your agent, but it doesn't make the knowledge unnecessary.</p>
<p><strong>One caveat on consent:</strong> A grant is per user, per provider. Alice connecting Slack but not Calendar means her calendar tool calls fail, and failure is correct there, since she never consented. Treat it as a prompt to connect rather than an error, a point the Failure Modes section returns to.</p>
<h2 id="heading-full-walkthrough">Full Walkthrough</h2>
<p>Clone the repository, install, and fill in <code>.env</code>:</p>
<pre><code class="language-bash">git clone https://github.com/saif-shines/channel-watcher-agent.git
cd channel-watcher-agent
npm install
cp .env.example .env
# fill in both client IDs and secrets, the encryption key, channel ID, repo
</code></pre>
<p>Then connect. The command starts the callback server and prints one link per unconnected provider:</p>
<pre><code class="language-bash">npm run connect
</code></pre>
<pre><code class="language-text">[slack] authorize as "channel-watcher-agent":
https://slack.com/oauth/v2/authorize?client_id=123.456&amp;user_scope=channels%3Ahistory%2Cchat%3Awrite%2Cusers%3Aread&amp;redirect_uri=https%3A%2F%2Flocalhost%3A3000%2Fcallback&amp;state=1159699dbf1a808fd33ba31c7b643505
[slack] connected.

[github] authorize as "channel-watcher-agent":
https://github.com/login/oauth/authorize?client_id=Iv1.abc&amp;scope=repo&amp;redirect_uri=https%3A%2F%2Flocalhost%3A3000%2Fcallback&amp;state=e6d13461099c391367266235f8313630
[github] connected.

All providers connected. Run: node src/index.js channel-watcher-agent
</code></pre>
<p>Open each link, consent, and the tab confirms. The state parameter in those URLs is checked on the way back. A callback carrying anything else gets a 400 and never reaches the token exchange.</p>
<p>Then run the agent against a channel holding ordinary chatter:</p>
<pre><code class="language-bash">node src/index.js
</code></pre>
<p>The output from that run, against a channel with three unremarkable messages:</p>
<pre><code class="language-text">[channel-watcher-agent] 3 messages fetched, 3 new.

--- Alex: "Sending draft message" ---
The message "Sending draft message" is noise — it appears to be a test or
accidental send, not a bug report or concrete action item.

...
</code></pre>
<p><strong>No tools called, and no issues filed.</strong> The negative case matters more than it looks. An agent with write access that can't say no is a liability, and a run over ordinary chatter is the cheapest available test of its restraint.</p>
<p>Now post an actual bug report in the channel:</p>
<blockquote>
<p>hey the /export endpoint is timing out for any file over 50MB, been happening since yesterday's deploy</p>
</blockquote>
<p>Run the agent again, and the state file keeps the earlier messages from being triaged twice.</p>
<p><strong>Authorship is the part that matters.</strong> The GitHub account behind the identifier opens the issue, using that user's own OAuth grant, not a shared bot. The Slack reply comes from that person too. Revoke their access and the next run fails at <code>getAccessToken</code>, which is the correct outcome.</p>
<h3 id="heading-what-changes-for-a-second-user">What Changes for a Second User</h3>
<p>The identifier comes from the command line, so isolation is testable without building a login first:</p>
<pre><code class="language-bash">node src/index.js                     # the identifier you already authorized
node src/index.js alice@example.com   # a different user entirely
</code></pre>
<p>The second command never reads the channel. It stops:</p>
<pre><code class="language-text">[slack] no grant for "alice@example.com".
Connect it first: node src/connect.js alice@example.com
</code></pre>
<p><strong>The refusal is the whole point.</strong> Nothing about the agent changed between the two commands. Same providers, tools, and code. Only the identifier differed, and Alice hasn't consented, so no row exists to decrypt and the run stops before touching Slack.</p>
<p>A shared-bot version behaves differently. The second command would read the channel and file an issue as the bot, because no per-user grant was ever involved.</p>
<p>Once Alice consents, everything downstream follows her grant. <code>readGrant</code> returns her row. <code>getAccessToken</code> decrypts her token. The Slack read returns the channels she can see, and her GitHub account authors the issue.</p>
<p>Production replaces <code>argv</code> with a session lookup:</p>
<pre><code class="language-javascript">const identifier = await lookupIdentifier(session.userId);
</code></pre>
<h3 id="heading-testing-the-isolation-without-credentials">Testing the Isolation Without Credentials</h3>
<p>The repository includes a test suite that replaces <code>fetch</code> with stand-in Slack and GitHub endpoints, so the request building, response parsing, storage, and refresh logic all run without a single OAuth app registered:</p>
<pre><code class="language-bash">npm test
</code></pre>
<p>Three of those tests are worth naming, because they check the claims this tutorial makes rather than the code's internals:</p>
<ul>
<li><p><strong>Slack exchange keeps the user token and discards the bot token.</strong> The fixture returns both. The test asserts the stored value is the <code>xoxp-</code> one.</p>
</li>
<li><p><strong>Two users get two different tokens from identical tool inputs.</strong> Same <code>text</code>, same <code>thread_ts</code>, two identifiers, two different <code>Authorization</code> headers reaching the provider.</p>
</li>
<li><p><strong>A tool built for an unconnected user fails instead of falling back.</strong> It also asserts that zero provider calls were attempted, since failing after leaking a request isn't much of a failure.</p>
</li>
</ul>
<p>Tests that pass on the first run are worth distrusting, so I checked these by breaking the code on purpose. Substituting the bot token for the user token, ignoring the identifier in <code>buildTools</code>, and exposing <code>identifier</code> in the model-visible schema each fail at least one test.</p>
<h2 id="heading-how-to-apply-the-pattern-to-other-use-cases">How to Apply the Pattern to Other Use Cases</h2>
<p><strong>Nothing in the pattern is specific to Slack triage.</strong> The shape is: read from one app, decide with a model, write to another app, all as one user.</p>
<p>Swapping the providers produces a different product:</p>
<table>
<thead>
<tr>
<th>Read from</th>
<th>Write to</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td>Slack</td>
<td>GitHub</td>
<td>Triage channel chatter into issues, as in this tutorial</td>
</tr>
<tr>
<td>Gmail</td>
<td>Linear</td>
<td>Turn support email into tracked work</td>
</tr>
<tr>
<td>Google Calendar</td>
<td>Notion</td>
<td>Meeting prep notes, drafted before the meeting</td>
</tr>
<tr>
<td>Zendesk</td>
<td>Salesforce</td>
<td>Log support signals against the right account</td>
</tr>
<tr>
<td>GitHub</td>
<td>Slack</td>
<td>A digest of what changed, in the channel that cares</td>
</tr>
</tbody></table>
<p>Every row uses the same three pieces: a provider entry, a token lookup by identifier, and a tool. Only three things change: the OAuth app registrations, the API calls inside <code>execute</code>, and the input you write for the model.</p>
<p><strong>The input is where your product lives.</strong> OAuth is plumbing. Deciding which messages deserve an issue, and what the issue should say, is judgment, and judgment is the part worth your weeks.</p>
<p>The same code supports two deployment shapes:</p>
<ul>
<li><p><strong>Internal team agent:</strong> The identifier is the teammate who triggered the run. Runs on a schedule or a command.</p>
</li>
<li><p><strong>Customer-facing agent:</strong> The identifier comes from your tenant and user records. Runs on customer data, inside customer accounts.</p>
</li>
</ul>
<p>The code stays identical. The consequences of a wrong identifier do not.</p>
<h2 id="heading-what-went-wrong-when-i-built-this">What Went Wrong When I Built This</h2>
<p>These are problems I hit while building the project, in roughly the order they showed up. If you hit the same ones, the fix is usually small.</p>
<h3 id="heading-slack-wont-save-the-redirect-url">Slack Won't Save the Redirect URL</h3>
<p>The symptom arrives before any code runs: the Slack app configuration page refuses to accept <code>http://localhost:3000/callback</code>.</p>
<p>Slack requires HTTPS on redirect URLs with no exception for <code>localhost</code>. Issue a local certificate with <code>mkcert</code>, register the <code>https://</code> form, and point <code>TLS_CERT_PATH</code> and <code>TLS_KEY_PATH</code> at the files it wrote. GitHub accepts either scheme, so the same HTTPS URL works for both apps.</p>
<h3 id="heading-the-browser-warns-that-the-certificate-isnt-trusted">The Browser Warns That the Certificate Isn't Trusted</h3>
<p><code>mkcert -install</code> is the step that adds mkcert's local authority to your system trust store, and skipping it leaves a certificate no browser recognises.</p>
<p>Running it once fixes every certificate mkcert issues afterwards. A self-signed certificate made with <code>openssl</code> will always warn, since nothing trusts it.</p>
<h3 id="heading-the-redirect-uri-doesnt-match">The Redirect URI Doesn't Match</h3>
<p>Both providers compare the <code>redirect_uri</code> you send against the one registered with the app, and the comparison is exact. A trailing slash, <code>127.0.0.1</code> in place of <code>localhost</code>, <code>http</code> where you registered <code>https</code>, or a different port all fail.</p>
<p>The error arrives before consent, on the provider's own page, which at least makes it easy to spot. Keep <code>OAUTH_REDIRECT_URI</code> as the single source and pass it in both the authorize URL and the token exchange, as the provider registry does.</p>
<h3 id="heading-the-callback-port-is-already-in-use">The Callback Port is Already in Use</h3>
<p><code>connect.js</code> binds the port from <code>OAUTH_REDIRECT_URI</code>, and port 3000 is popular. An unhandled <code>EADDRINUSE</code> produces a stack trace that says nothing about OAuth, so the project catches it and says what to do instead.</p>
<p>Changing the port means changing it in three places: <code>.env</code>, the Slack app's redirect URLs, and the GitHub app's callback URL. Missing one produces the previous failure.</p>
<h3 id="heading-the-state-check-rejects-a-legitimate-callback">The State Check Rejects a Legitimate Callback</h3>
<p>State values live in memory and are deleted once used. Restarting <code>connect.js</code> after opening the link, or refreshing the callback tab, both produce a state that's no longer in the map.</p>
<p>Both are correct rejections. Generate a fresh link and start again.</p>
<h3 id="heading-tool-calls-return-permission-errors-or-empty-results">Tool Calls Return Permission Errors or Empty Results</h3>
<p>A missing scope or a revoked grant causes both.</p>
<p>GitHub answers <code>403</code> with "Resource not accessible" when the grant lacks <code>repo</code>. Slack answers <code>200</code> with <code>ok: false</code> and an error like <code>missing_scope</code>. Fix the scope list, then send the user through consent again, since an existing grant doesn't gain scopes retroactively.</p>
<p><strong>A partially-scoped grant fails at the point of use rather than at connection time</strong>, which is what makes the symptom look mysterious. The consent screen succeeded, the token stored fine, and the failure arrives during a tool call hours later.</p>
<h3 id="heading-the-agent-reads-channels-it-shouldnt">The Agent Reads Channels it Shouldn't</h3>
<p>The single most likely cause is storing <code>json.access_token</code> instead of <code>json.authed_user.access_token</code> during the Slack exchange. Both are strings, both are truthy, and both work (one works as the app rather than the person).</p>
<p>The tell is the scope of what comes back. A user token returns only that person's channels. If <code>conversations.history</code> returns a channel the current user was never in, a bot token is in the store.</p>
<h3 id="heading-a-tool-call-runs-as-the-wrong-user">A Tool Call Runs as the Wrong User</h3>
<p>Passing a token or identifier belonging to somebody else will do the wrong thing correctly.</p>
<p>Two habits prevent it. Resolve the identifier server-side after authenticating the caller, never from client input. Then take the identifier as a closure argument in <code>buildTools</code> and let each <code>execute</code> fetch its own token, so no code path can pass a stray credential.</p>
<h3 id="heading-refresh-works-once-and-then-stops">Refresh Works Once and Then Stops</h3>
<p>Refresh tokens rotate. A refresh that writes back the new access token but keeps the old refresh token succeeds immediately and fails on the following cycle, which puts twelve hours between the bug and its symptom.</p>
<p><code>saveGrant</code> takes the whole normalized token object for this reason. Write back everything the refresh returned.</p>
<h3 id="heading-the-agent-files-duplicate-issues">The Agent Files Duplicate Issues</h3>
<p>Two causes. A missing or unwritten state file makes every run triage everything again. Or <code>stopWhen</code> allows enough rounds for a confused model to retry a tool that already succeeded.</p>
<p>Check the state file first. Then check whether the tool's return value clearly signals success, because an ambiguous result invites a retry.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You've built an agent that reads a Slack channel, judges which messages describe real work, files GitHub issues for those, and closes the loop with a threaded reply. Every call ran as one specific user's own OAuth grant, through an OAuth flow and a token store you wrote yourself.</p>
<p>Five ideas carry over to any provider:</p>
<ul>
<li><p><strong>A tool is an API call plus an explanation for a model</strong>, and the explanation is most of the work.</p>
</li>
<li><p><strong>The identifier replaces the token in your agent code.</strong> Everything above one small function handles a reference to a user rather than a credential, so tokens never reach your model inputs or your logs.</p>
</li>
<li><p><strong>Connection time and runtime are separate flows.</strong> Consent happens once per user, per app. Runtime resolves an identifier and fetches a token late.</p>
</li>
<li><p><strong>Authorization stays yours.</strong> A token store answers which tokens belong to an identifier. Whether a caller may act as that identifier is a question only your code can answer.</p>
</li>
<li><p><strong>Multi-provider support is a registry problem, not an architecture problem</strong>, once identity lives in one string.</p>
</li>
</ul>
<p>The detail that carries the most weight is also the smallest: <code>authed_user.access_token</code> rather than <code>access_token</code>. One property access decides whether your agent respects the permissions your workspace already has or quietly routes around them.</p>
<p>From here, keep the shape and swap the providers. Point the read half at Gmail and the write half at Linear, then rewrite the input for the model. The identity plumbing doesn't change.</p>
<p>The full source is at <a href="https://github.com/saif-shines/channel-watcher-agent">github.com/saif-shines/channel-watcher-agent</a>.</p>
<p><em>This write-up reconstructs what we learned building</em> <a href="https://www.scalekit.com/"><em>Scalekit</em></a><em>, a hosted version of the token vault you just built.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Fix the Dual-Write Problem in Node.js with the Outbox Pattern ]]>
                </title>
                <description>
                    <![CDATA[ Imagine you're building an e-commerce platform where placing an order needs to trigger several things at once: the warehouse has to be told to prepare the shipment, the email service has to send a con ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-fix-the-dual-write-problem-in-node-js-with-the-outbox-pattern/</link>
                <guid isPermaLink="false">6a736f87fcec1e65edd2a703</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ design patterns ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AWS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Gabor Koos ]]>
                </dc:creator>
                <pubDate>Wed, 05 Aug 2026 17:14:47 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/bcec9aaf-d418-4e5a-b8aa-f3c75b35f482.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Imagine you're building an e-commerce platform where placing an order needs to trigger several things at once: the warehouse has to be told to prepare the shipment, the email service has to send a confirmation, and the fraud checker has to review the transaction.</p>
<p>The order service handles the checkout, saves the order to its database, and then publishes an <code>order.created</code> event to a message queue so every downstream system can react independently.</p>
<p>This is a common and reasonable design, but it has a reliability problem that's easy to miss until something goes wrong in production.</p>
<p>When a customer places an order and the payment goes through, the application needs to do two things: save the order to the database and publish the event to the queue. These are two separate writes to two separate systems, and there's no way to make them share a single atomic transaction. If the process crashes, the network hiccups, or a deployment rolls out between the two writes, one side commits and the other does not. The order sits confirmed on the customer's screen while the warehouse has no idea it exists.</p>
<p>The <a href="https://microservices.io/patterns/data/transactional-outbox.html">transactional outbox pattern</a> is the standard solution to this problem. In this article, we'll build it from scratch in Node.js, using PostgreSQL for the order service database, SQS for the queue, and DynamoDB as the fulfillment service's database. For local development, we'll use <a href="https://floci.io">floci</a>, a free open-source AWS emulator that runs all three with a single Docker container.</p>
<h2 id="heading-what-well-cover">What We'll Cover</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-problem-with-two-writes">The Problem with Two Writes</a></p>
</li>
<li><p><a href="#heading-the-outbox-pattern">The Outbox Pattern</a></p>
</li>
<li><p><a href="#heading-what-well-build">What We'll Build</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-database-schema">Database Schema</a></p>
</li>
<li><p><a href="#heading-the-request-handler">The Request Handler</a></p>
</li>
<li><p><a href="#heading-the-relay-worker">The Relay Worker</a></p>
</li>
<li><p><a href="#heading-the-consumer">The Consumer</a></p>
</li>
<li><p><a href="#heading-running-the-whole-thing">Running the Whole Thing</a></p>
</li>
<li><p><a href="#heading-going-to-production">Going to Production</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should be comfortable with:</p>
<ul>
<li><p>Node.js and async/await</p>
</li>
<li><p>Database transactions (BEGIN, COMMIT, ROLLBACK)</p>
</li>
<li><p>The general concept of a message queue</p>
</li>
</ul>
<p>You don't need prior experience with AWS, SQS, or DynamoDB. We'll be running everything locally.</p>
<p>You will need Node.js 20 or later and Docker installed on your machine.</p>
<h2 id="heading-the-problem-with-two-writes">The Problem with Two Writes</h2>
<p>The order service scenario from the intro is one place this problem appears, but the same pattern comes up in many other contexts.</p>
<p>A user registers and the app inserts their account record, then sends a message to trigger the welcome email and the onboarding workflow. A file is uploaded and the API writes the metadata to the database, then publishes a message to kick off a processing worker for virus scanning or thumbnail generation. A payment webhook arrives, the handler records it in the database, then notifies downstream services that the payment is confirmed.</p>
<p>In every case, the application needs two writes to succeed together: one to the database and one to a queue or external system. If the second one is lost, the first one has no way of knowing.</p>
<p>If you want a deeper look at what database transactions actually guarantee and where they stop helping, see <a href="https://blog.gaborkoos.com/posts/2026-08-01-Beyond-Happy-Path-Engineering-Databases/">Beyond Happy Path Engineering: Databases</a>.</p>
<p>The naïve implementation looks straightforward:</p>
<pre><code class="language-js">await db.query('INSERT INTO orders (customer_id, amount_cents) VALUES ($1, $2)', [customerId, amountCents]);
await sqs.send(new SendMessageCommand({ QueueUrl: QUEUE_URL, MessageBody: JSON.stringify({ customerId, amountCents }) }));
</code></pre>
<p>The database write happens first, then the queue write. Under normal conditions this works fine. The problem is what happens when something goes wrong between the two.</p>
<p>If the process crashes, runs out of memory, or gets killed mid-deployment after the database write but before <code>sqs.send</code> is called, the order record exists in the database but no event is ever published. The warehouse, email service, and fraud checker never find out the order happened. From the customer's perspective the order went through. From every downstream system's perspective it doesn't exist.</p>
<p>The failure can also go the other way. If <code>sqs.send</code> succeeds but the database write is later rolled back due to a constraint violation or an error in a subsequent step, you've published an event for an order that doesn't actually exist. A consumer acting on that event may try to fulfill an order with no corresponding record, or charge a customer for something that was never saved.</p>
<p>There's also a timing window even when both writes eventually succeed. Between the database commit and the successful <code>sqs.send</code>, a consumer that queries the database after receiving the event may not find the order yet, depending on transaction isolation and replication lag. These are two separate systems with no shared transaction boundary, and no amount of careful sequencing fully closes the gap.</p>
<p>These aren't edge cases that only happen under extraordinary circumstances. Deploys restart processes mid-request. Out-of-memory kills happen without warning. Networks drop connections at any point. Any of these can interrupt the two-write sequence, and the result is a system that's silently inconsistent with no error logged and no alert fired.</p>
<p>A variation I've seen a few times that looks safer but is actually worse is wrapping both operations in a database transaction:</p>
<pre><code class="language-js">// PLEASE DO NOT EVER DO THIS
const client = await pool.connect();
await client.query('BEGIN');
await client.query('INSERT INTO orders (customer_id, amount_cents) VALUES ($1, $2)', [customerId, amountCents]);
await sqs.send(new SendMessageCommand({ QueueUrl: QUEUE_URL, MessageBody: JSON.stringify({ customerId, amountCents }) }));
await client.query('COMMIT');
</code></pre>
<p>The intent is to make the two writes feel like a unit, but a database transaction has no authority over SQS. The transaction can only roll back database operations. If <code>sqs.send</code> succeeds and then <code>COMMIT</code> fails, the message is already in the queue and can't be taken back. If the process crashes after <code>COMMIT</code> but before the function returns, the transaction committed and the message was sent, but the caller may retry, potentially inserting a duplicate order.</p>
<p>Beyond the correctness problems, this pattern holds an open database connection and any row locks for the entire duration of the SQS network call. SQS is normally fast, but under load, retries, or a degraded queue, that call can take seconds. Every other request trying to read or write the same rows has to wait. In a busy application, this is a reliable way to exhaust the connection pool and bring down unrelated parts of the service.</p>
<h2 id="heading-the-outbox-pattern">The Outbox Pattern</h2>
<p>The core idea is to stop treating the queue publish as a second write that happens after the database write, and instead make it part of the same database transaction.</p>
<p>Rather than calling <code>sqs.send</code> directly, the application inserts a row into an <code>outbox</code> table in the same transaction as the business record. A separate relay process reads the outbox table and publishes the messages to SQS. On the other end, a consumer receives the messages and writes to its own data store. In our case that is a fulfillment service writing to DynamoDB, completely separate from the order service's PostgreSQL database.</p>
<p>If the transaction rolls back for any reason, the outbox row disappears with it. There's no orphaned message in the queue because the message was never sent. If the application crashes after committing but before the relay runs, the outbox row is still there with <code>status='pending'</code>, and the relay will pick it up on its next iteration.</p>
<p>The only guarantee the pattern relies on is the one the database already provides: atomicity within a single transaction.</p>
<p>The relay worker is responsible for the eventual delivery guarantee. It runs on an interval, selects pending rows, publishes them to SQS, and marks them as sent only after SQS confirms receipt. If the relay crashes mid-run, it will reprocess the same rows on the next iteration, which means SQS may receive some messages more than once.</p>
<p>That's why the consumer needs to be <strong>idempotent</strong>: it must handle receiving the same message twice without creating duplicate fulfillment records. We'll cover how to implement that when we build the consumer.</p>
<p>This separation of concerns is what makes the pattern practical. The request handler commits one atomic database transaction and returns. The relay handles the network call to SQS asynchronously, at its own pace, with its own retry logic, without holding database connections open or blocking request handling. The consumer is fully decoupled from the order service and owns its own data store.</p>
<p>The diagram below illustrates the flow:</p>
<img src="https://cdn.hashnode.com/uploads/covers/68b08746916c71e1ed2db58e/ab0620f0-65c6-43f1-a406-00bfd4880cdc.svg" alt="Diagram: outbox pattern flow" style="display:block;margin:0 auto" width="960" height="640" loading="lazy">

<h2 id="heading-what-well-build">What We'll Build</h2>
<p>Now let's see the whole thing in practice. We'll implement a simple order placement API. When a customer sends a request to place an order, the order service saves it to PostgreSQL and inserts a row into the outbox table, all in one atomic transaction. A relay worker wakes up periodically, reads the pending outbox rows, and publishes each one as a message to SQS. A separate fulfillment service receives those messages from the queue and creates fulfillment records in DynamoDB.</p>
<p>By the end, you'll have an HTTP endpoint you can call, and you'll be able to verify that placing an order triggers the creation of a fulfillment record in a completely separate database, owned by a completely separate service, without either service ever talking to the other directly.</p>
<p>You can find the complete working code at <a href="https://github.com/gkoos/article-outbox">github.com/gkoos/article-outbox</a>.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>Before you can run any code, you need to get floci running so you have local instances of PostgreSQL, SQS, and DynamoDB. You'll also need Node.js 20 or later and Docker installed.</p>
<p>Start by cloning the repository and installing dependencies:</p>
<pre><code class="language-bash">git clone https://github.com/gkoos/article-outbox
cd article-outbox
npm install
</code></pre>
<p>Next, start floci. This command pulls the latest floci image and starts a Docker container that exposes a local AWS API endpoint (make sure Docker is running):</p>
<pre><code class="language-bash">npm run floci:start
</code></pre>
<p>On Linux and macOS, this just works. On Windows with Docker Desktop, <strong>you need to edit the</strong> <code>floci:start</code> <strong>script in your</strong> <code>package.json</code> <strong>to change the Docker socket mount from</strong> <code>/var/run/docker.sock</code> <strong>to</strong> <code>//var/run/docker.sock</code>.</p>
<p>The floci container is now listening on port 4566 and can spin up RDS (PostgreSQL), SQS, and DynamoDB instances on demand.</p>
<p>Now provision the AWS resources with a single setup command:</p>
<pre><code class="language-bash">npm run setup
</code></pre>
<p>This script creates an RDS PostgreSQL database instance, an SQS queue named <code>orders</code>, and a DynamoDB table named <code>fulfillments</code>. It waits for RDS to become available and then writes a <code>.env</code> file with the correct connection details. The environment variables <code>PG_PORT</code>, <code>SQS_QUEUE_URL</code>, and <code>DYNAMODB_TABLE_NAME</code> now point to the local emulated services.</p>
<p>Finally, create the PostgreSQL tables:</p>
<pre><code class="language-bash">npm run migrate
</code></pre>
<p>This creates the <code>orders</code> table and the <code>outbox</code> table in PostgreSQL. You now have a fully functional local environment ready to build against.</p>
<h2 id="heading-database-schema">Database Schema</h2>
<p>The two tables are simple. <code>orders</code> holds the business records: each order has a customer ID, an amount in cents, and a timestamp. The <code>outbox</code> table is the heart of the pattern: it's where the application writes the event that needs to be published.</p>
<pre><code class="language-sql">CREATE TABLE orders (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id TEXT NOT NULL,
  amount_cents INTEGER NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE outbox (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  event_type TEXT NOT NULL,
  payload JSONB NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending',
  created_at TIMESTAMPTZ DEFAULT now(),
  sent_at TIMESTAMPTZ
);

CREATE INDEX ON outbox (status, created_at) WHERE status = 'pending';
</code></pre>
<p>The <code>orders</code> table needs nothing special. The <code>outbox</code> table stores the event metadata: what type of event it is (<code>event_type</code>), what data it contains (<code>payload</code> as JSON), and whether it has been sent yet (<code>status</code>).</p>
<p>The status starts as <code>pending</code>. When the relay publishes it to SQS, it will mark it as <code>sent</code> and record the timestamp. The index on <code>(status, created_at) WHERE status = 'pending'</code> lets the relay quickly find the next batch of unsent events without scanning the entire table.</p>
<h2 id="heading-the-request-handler">The Request Handler</h2>
<p>This is where the pattern starts. The request handler receives an HTTP POST, inserts an order into the database, inserts a corresponding row into the outbox table, and commits everything in a single atomic transaction. The key insight is that neither write succeeds unless both succeed.</p>
<pre><code class="language-js">const client = await pool.connect();
try {
  await client.query('BEGIN');

  // Insert the order record
  const { rows } = await client.query(
    'INSERT INTO orders (customer_id, amount_cents) VALUES ($1, $2) RETURNING *',
    [customerId, amountCents]
  );
  const order = rows[0];

  // Insert the outbox record in the same transaction
  await client.query(
    `INSERT INTO outbox (event_type, payload)
     VALUES ($1, $2)`,
    ['order.created', JSON.stringify({ orderId: order.id, customerId: order.customer_id, amountCents: order.amount_cents, createdAt: order.created_at })],
  );

  await client.query('COMMIT');
  res.status(201).json(order);
} catch (err) {
  await client.query('ROLLBACK');
  next(err);
} finally {
  client.release();
}
</code></pre>
<p>The handler gets <code>customerId</code> and <code>amountCents</code> from the request body, starts an explicit transaction with <code>BEGIN</code>, and inserts the order. Then it inserts an outbox row with the order data as the payload.</p>
<p>Everything commits atomically. If anything fails, everything rolls back and the client gets an error. If the process crashes between the commit and the response, the client won't get a 201, but the order and the outbox row are still safely committed to the database and the relay will eventually pick it up. The handler doesn't call SQS at all. That is the relay's job.</p>
<h2 id="heading-the-relay-worker">The Relay Worker</h2>
<p>The relay worker is a separate process that polls the outbox table every second and publishes pending rows to SQS. It runs independently of the HTTP server and has no shared state with it.</p>
<pre><code class="language-js">async function relay() {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    const { rows } = await client.query(`
      SELECT *
      FROM outbox
      WHERE status = 'pending'
      ORDER BY created_at
      LIMIT 10
      FOR UPDATE SKIP LOCKED -- prevents multiple relays from processing the same rows
    `);

    for (const row of rows) {
      await sqsClient.send(new SendMessageCommand({
        QueueUrl: QUEUE_URL,
        MessageBody: JSON.stringify(row.payload),
        MessageAttributes: {
          EventType: { DataType: 'String', StringValue: row.event_type },
        },
      }));

      await client.query(
        `UPDATE outbox SET status = 'sent', sent_at = now() WHERE id = $1`,
        [row.id],
      );
    }

    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    console.error('Relay error:', err.message);
  } finally {
    client.release();
  }
}

setInterval(relay, 1000);
</code></pre>
<p><code>FOR UPDATE SKIP LOCKED</code> is the key to running multiple relay instances safely: when a relay picks up a batch of rows, it locks them. Any other relay instance trying to select the same rows will skip them and move to the next available ones, so you never get two relays publishing the same message from the same run.</p>
<p>The relay marks each row as <code>sent</code> only after <code>sqsClient.send</code> returns. If the relay crashes after sending to SQS but before updating the row, the row stays <code>pending</code> and the relay will resend it on the next iteration.</p>
<p>Note that the <code>UPDATE</code> happens inside the same transaction as the <code>SELECT FOR UPDATE</code>, so if the relay crashes mid-batch, the entire batch rolls back and all rows in it will be retried, including any that were already successfully sent to SQS.</p>
<p>The at-least-once delivery guarantee applies at the batch level, not the individual row level. You can read about this problem in <a href="https://blog.gaborkoos.com/posts/2026-07-01-Beyond-Happy-Path-Engineering-the-Network/">Beyond Happy Path Engineering: the Network</a>: when a response is lost, the caller can't know whether the operation succeeded, so it retries, and the receiver may see the same request twice. This means the consumer may see the same message more than once, which is why idempotency matters on the consumer side.</p>
<h2 id="heading-the-consumer">The Consumer</h2>
<p>The consumer is a completely separate service. It knows nothing about the order service's PostgreSQL database. Its only input is the SQS queue, and its only output is the DynamoDB <code>fulfillments</code> table. This is the point of the pattern: the two services are decoupled by the queue, and each owns its own data store.</p>
<p>As we saw earlier, because SQS delivers at least once (meaning a message might be delivered more than once), the consumer must be idempotent. The <code>PutItem</code> call uses a <code>ConditionExpression</code> that makes the write a no-op if a fulfillment record for that order already exists, so redelivered messages are handled safely.</p>
<pre><code class="language-js">async function consume() {
  const { Messages } = await sqsClient.send(new ReceiveMessageCommand({
    QueueUrl:              QUEUE_URL,
    WaitTimeSeconds:       20,   // long-poll: wait up to 20s for messages
    MaxNumberOfMessages:   10,
    MessageAttributeNames: ['All'],
  }));

  for (const msg of Messages ?? []) {
    const event = JSON.parse(msg.Body);

    try {
      await dynamoClient.send(new PutItemCommand({
        TableName: 'fulfillments',
        Item: {
          orderId:     { S: event.orderId },
          customerId:  { S: event.customerId },
          amountCents: { N: String(event.amountCents) },
          status:      { S: 'received' },
          createdAt:   { S: new Date().toISOString() },
        },
        ConditionExpression: 'attribute_not_exists(orderId)', // idempotency check
      }));
    } catch (err) {
      if (err.name !== 'ConditionalCheckFailedException') throw err;
      // already processed, safe to continue
    }

    // delete the message only after the write succeeds (or was already done)
    await sqsClient.send(new DeleteMessageCommand({
      QueueUrl:      QUEUE_URL,
      ReceiptHandle: msg.ReceiptHandle,
    }));
  }
}
</code></pre>
<p><code>ConditionExpression: 'attribute_not_exists(orderId)'</code> tells DynamoDB to reject the write if a record with that <code>orderId</code> already exists. When that happens, DynamoDB throws a <code>ConditionalCheckFailedException</code>. The consumer catches that specific error and ignores it, then deletes the message from the queue and moves on. Any other error is rethrown and the message stays in the queue to be retried.</p>
<p>The <code>DeleteMessage</code> call happens after the DynamoDB write, not before. If the process crashes between the write and the delete, SQS will redeliver the message and the condition check will handle it. If the process crashes before the write, the message stays in the queue and will be processed normally on the next delivery.</p>
<h2 id="heading-running-the-whole-thing">Running the Whole Thing</h2>
<p>With floci running and the resources provisioned, open three terminal tabs and start each process:</p>
<pre><code class="language-bash">node src/server.js    # the order API on port 3000
node src/relay.js     # the outbox relay
node src/consumer.js  # the fulfillment consumer
</code></pre>
<p>Now place an order:</p>
<pre><code class="language-bash">curl -X POST localhost:3000/orders \
  -H 'Content-Type: application/json' \
  -d '{"customerId":"c1","amountCents":4999}'
</code></pre>
<p>You should get back a 201 with the new order record:</p>
<pre><code class="language-bash">{"id":"1768d35b-083d-45f1-adb5-4063d8d7fcab","customer_id":"c1","amount_cents":4999,"created_at":"2026-07-30T20:27:10.628Z"}
</code></pre>
<p>Within a second the relay will pick up the outbox row and publish it to SQS. The consumer will receive the message and write a fulfillment record to DynamoDB. The repo includes a convenience script to verify this:</p>
<pre><code class="language-bash">npm run check
</code></pre>
<p>You should see a fulfillment record with the <code>orderId</code> from the order you just placed:</p>
<pre><code class="language-bash">{
  orderId: 'c335640e-bc4a-47e4-afed-484c95fbd6d3',
  customerId: 'c1',
  amountCents: '4999',
  status: 'received',
  createdAt: '2026-07-30T19:02:54.929Z'
}
</code></pre>
<h2 id="heading-going-to-production">Going to Production</h2>
<p>Because the local setup uses floci to emulate AWS, switching to real AWS requires no code changes at all. The AWS SDK reads the endpoint from <code>AWS_ENDPOINT_URL</code> in the environment. In production, you simply don't set that variable and the SDK talks to real AWS using the credentials and region from the standard environment variables (<code>AWS_REGION</code>, <code>AWS_ACCESS_KEY_ID</code>, <code>AWS_SECRET_ACCESS_KEY</code>, or an IAM role if you are running on EC2 or ECS).</p>
<p>Running multiple relay instances is safe out of the box because of <code>FOR UPDATE SKIP LOCKED</code>. You can scale the relay horizontally and each instance will pick up a different set of rows without duplicating messages.</p>
<p>One thing worth adding before going to production is handling permanent failures in the relay. Right now the relay only uses <code>pending</code> and <code>sent</code>. You should add a <code>failed</code> status and a retry counter: after a row has failed N times, mark it <code>failed</code> and stop retrying it. Then configure a dead-letter queue on the <code>orders</code> SQS queue as well, so that messages the consumer can't process after the maximum number of retries land somewhere you can inspect rather than disappearing silently.</p>
<p>For high-throughput systems where polling latency matters, <a href="https://en.wikipedia.org/wiki/Change_data_capture">change data capture</a> (CDC) is a common alternative to the polling relay. Tools like <a href="https://debezium.io/">Debezium</a> read directly from the PostgreSQL write-ahead log and publish changes to <a href="https://kafka.apache.org/">Kafka</a> or SQS without any polling delay. The outbox table and the consumer stay exactly the same, only the relay is replaced.</p>
<p>This is a bigger operational commitment than a polling worker, so polling is the right starting point for most systems.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The dual-write problem is easy to overlook because the naïve implementation works correctly most of the time. It only fails in the gaps between two separate system writes, and those gaps only become visible when something goes wrong at exactly the wrong moment. By the time you notice it in production, data is already inconsistent and there is no clean way to recover.</p>
<p>The transactional outbox pattern closes that gap at the database level. The outbox row is part of the same atomic commit as the business record, so the two are always in sync. The relay handles the network call to SQS independently, with its own retry logic, without touching the request lifecycle. The consumer handles at-least-once delivery with a single condition check on the write.</p>
<p>Each piece is simple on its own, and together they give you reliable, decoupled event delivery without distributed transactions.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AI Agent with Function Calling in Node.js Using Google Gemini ]]>
                </title>
                <description>
                    <![CDATA[ Last year, a client asked me to add a conversational interface to their internal reporting tool. Staff would type a question, and the system would pull a live answer from the database. I had the first ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-ai-agent-function-calling-nodejs-gemini/</link>
                <guid isPermaLink="false">6a63af5c9a1ab0289b0cbb6a</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tutorial ]]>
                    </category>
                
                    <category>
                        <![CDATA[ gemini ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Fri, 24 Jul 2026 18:30:52 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/ef2a5058-558c-4951-abff-4d51f1c5cd15.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Last year, a client asked me to add a conversational interface to their internal reporting tool. Staff would type a question, and the system would pull a live answer from the database.</p>
<p>I had the first version running in a day. Single questions worked. But a week in, a tester typed: "What is the weather in Berlin, and how much would 500 EUR convert to in USD right now?"</p>
<p>The model called the weather function, returned that answer, and ignored the second half of the question entirely.</p>
<p>That is the gap between a chatbot and an agent. A chatbot works from training data. That's its limit. An agent doesn't have that limit. It calls a tool, reads what came back, and decides whether to keep going.</p>
<p>Most questions resolve in one or two tool calls. Multi-step ones take a few more. Remove that loop, and they all break.</p>
<p>This tutorial shows you how to build that loop with Google Gemini's function calling API and Node.js. You'll build an agent that can call real external tools: Open-Meteo for live weather, frankfurter.app for live currency rates, and a math evaluator for calculations. All three are completely free. The only API key you need is Gemini, which is also free on Google AI Studio at 1,500 requests per day.</p>
<p>Everything is on GitHub: <a href="https://github.com/ziaongit/nodejs-gemini-agent">github.com/ziaongit/nodejs-gemini-agent</a>.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-how-function-calling-works">How Function Calling Works</a></p>
</li>
<li><p><a href="#heading-what-were-building">What We're Building</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-defining-the-tools">Defining the Tools</a></p>
</li>
<li><p><a href="#heading-implementing-the-tool-functions">Implementing the Tool Functions</a></p>
</li>
<li><p><a href="#heading-building-the-agentic-loop">Building the Agentic Loop</a></p>
</li>
<li><p><a href="#heading-the-cli-entry-point">The CLI Entry Point</a></p>
</li>
<li><p><a href="#heading-adding-an-express-http-server">Adding an Express HTTP Server</a></p>
</li>
<li><p><a href="#heading-testing-the-agent">Testing the Agent</a></p>
</li>
<li><p><a href="#heading-troubleshooting">Troubleshooting</a></p>
</li>
<li><p><a href="#heading-what-to-build-next">What to Build Next</a></p>
</li>
</ul>
<h2 id="heading-how-function-calling-works">How Function Calling Works</h2>
<p>Most LLM tutorials show function calling as: define a function, the model calls it, done. That framing skips the part that actually matters.</p>
<p>The model doesn't call your function. It can't. What happens is more like a negotiation.</p>
<p>You send the model a message along with a list of tool descriptions. Each description is a JSON schema: the function name, what it does, and what arguments it needs. Gemini reads those at request time to decide which tool, if any, fits what the user asked.</p>
<p>Here's the part that surprises people. Gemini doesn't run your code. It sends back a structured object that says: call <code>get_weather</code>, <code>city = Berlin</code>. Your code picks that up, runs the actual function, and sends the result back. Gemini checks whether that's enough to answer. If not, it requests another tool.</p>
<p>That exchange is the loop:</p>
<pre><code class="language-plaintext">User message
      │
      ▼
Model + tool schemas
      │
      ▼
Response: functionCall?
      │
   YES │                          NO
      ▼                            ▼
Run the function(s)         Return text answer
      │
      ▼
Send result(s) back to model
      │
      └──── loop back ────────────┘
</code></pre>
<p>The loop keeps running until the model decides it has enough to answer. That's what allows it to chain calls: check the weather, see the temperature is above 25°C, then decide it should also fetch the exchange rate before answering.</p>
<p>There's one detail that trips people up the first time. When Gemini requests multiple tools in the same response, you run all of them and return all results in a single message. Returning them one at a time in separate messages breaks the model's turn-tracking and produces unreliable output.</p>
<h2 id="heading-what-were-building">What We're Building</h2>
<p>In this tutorial, we'll build an AI agent with three working tools:</p>
<ul>
<li><p><code>get_weather</code> — fetches current weather for any city via Open-Meteo (free, no API key)</p>
</li>
<li><p><code>calculate</code> — evaluates a math expression safely in JavaScript</p>
</li>
<li><p><code>get_exchange_rate</code> — fetches live currency rates via frankfurter.app (free, no API key)</p>
</li>
</ul>
<p>There are two ways to run it: a readline CLI for quick local testing, and an Express HTTP endpoint to wire into a real application.</p>
<p>Full tech stack:</p>
<ul>
<li><p><strong>Node.js 20</strong>: runtime (Node 18 minimum for native fetch)</p>
</li>
<li><p><strong>@google/generative-ai</strong>: official Gemini SDK</p>
</li>
<li><p><strong>dotenv</strong>: environment variable loading</p>
</li>
<li><p><strong>Express</strong>: HTTP server for the API endpoint</p>
</li>
<li><p><strong>Open-Meteo API</strong>: free weather and geocoding, no key required</p>
</li>
<li><p><strong>frankfurter.app</strong>: free currency exchange rates, no key required</p>
</li>
</ul>
<p>Architecture:</p>
<pre><code class="language-plaintext">┌─────────────────────────────────────────────────┐
│                   Client                         │
│         CLI (readline) / HTTP POST               │
└──────────────────────┬──────────────────────────┘
                       │  user message
                       ▼
┌─────────────────────────────────────────────────┐
│               agent.js — Agentic Loop            │
│                                                  │
│  1. Send message + tool schemas to Gemini        │
│  2. Receive response                             │
│  3. functionCalls() present?                     │
│      YES → execute tools in parallel             │
│            send all results back                 │
│            go to step 2                          │
│      NO  → return final text answer              │
└──────────────────────┬──────────────────────────┘
                       │  tool calls
                       ▼
┌─────────────────────────────────────────────────┐
│                  Tool Handlers                   │
│                                                  │
│  get_weather(city)                               │
│    └─► geocoding-api.open-meteo.com              │
│        api.open-meteo.com                        │
│                                                  │
│  calculate(expression)                           │
│    └─► JS safe evaluator (no external call)      │
│                                                  │
│  get_exchange_rate(from, to, amount?)            │
│    └─► api.frankfurter.app                       │
└─────────────────────────────────────────────────┘
</code></pre>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start, you should have:</p>
<ul>
<li><p>Node.js 18 or higher — run <code>node --version</code> to check</p>
</li>
<li><p>A Gemini API key from <a href="https://aistudio.google.com">aistudio.google.com</a> — free, no card. The free tier gives you 1,500 requests a day.</p>
</li>
<li><p>You should also know how <code>async/await</code> works in Node.js. That's about it.</p>
</li>
</ul>
<h2 id="heading-project-setup">Project Setup</h2>
<pre><code class="language-bash">mkdir nodejs-gemini-agent &amp;&amp; cd nodejs-gemini-agent
npm init -y
npm install @google/generative-ai dotenv express
mkdir src
</code></pre>
<p>Add a <code>.gitignore</code>, as you don't want <code>.env</code> in your repo:</p>
<pre><code class="language-plaintext">node_modules/
.env
</code></pre>
<p>Drop a <code>.env</code> at the root:</p>
<pre><code class="language-plaintext">GEMINI_API_KEY=your_api_key_here
PORT=3000

# Optional: override the default model (gemini-2.0-flash)
# Uncomment if you hit free-tier quota limits
# GEMINI_MODEL=gemini-2.0-flash-lite
</code></pre>
<p>Project structure:</p>
<pre><code class="language-plaintext">nodejs-gemini-agent/
├── src/
│   ├── tools.js        ← tool schemas for Gemini
│   ├── functions.js    ← actual implementations
│   ├── agent.js        ← the agentic loop
│   ├── index.js        ← CLI entry point
│   └── server.js       ← Express HTTP server
├── .env
├── .env.example
├── .gitignore
└── package.json
</code></pre>
<h2 id="heading-defining-the-tools">Defining the Tools</h2>
<p>Gemini can't see your code. It picks tools based entirely on the JSON schemas you pass in. Each schema has a name, a description, and the parameter definitions.</p>
<p>The description is what drives routing. Gemini reads it at request time to decide which tool fits the user's question. Focus on when to call the function, not just what it does.</p>
<pre><code class="language-js">// src/tools.js

const toolDefinitions = [
  {
    name: 'get_weather',
    description:
      'Get the current weather for a city. Returns temperature in Celsius, ' +
      'humidity percentage, and wind speed. Use this when the user asks about ' +
      'weather, temperature, or climate conditions in any location.',
    parameters: {
      type: 'OBJECT',
      properties: {
        city: {
          type: 'STRING',
          description: 'The city name, e.g. Tokyo, London, New York',
        },
      },
      required: ['city'],
    },
  },
  {
    name: 'calculate',
    description:
      'Evaluate a mathematical expression and return the numeric result. ' +
      'Use this for arithmetic, percentage calculations, or any numeric computation ' +
      'the user asks for. Do not guess at math — always call this tool.',
    parameters: {
      type: 'OBJECT',
      properties: {
        expression: {
          type: 'STRING',
          description:
            'A valid mathematical expression, e.g. "47.50 * 0.18" or "1500 / 12"',
        },
      },
      required: ['expression'],
    },
  },
  {
    name: 'get_exchange_rate',
    description:
      'Get the current exchange rate between two currencies. Can also convert ' +
      'a specific amount. Use this when the user asks about currency conversion, ' +
      'exchange rates, or how much a foreign currency amount is worth.',
    parameters: {
      type: 'OBJECT',
      properties: {
        from: {
          type: 'STRING',
          description: 'The source currency code, e.g. USD, EUR, JPY, GBP',
        },
        to: {
          type: 'STRING',
          description: 'The target currency code, e.g. USD, EUR, JPY, GBP',
        },
        amount: {
          type: 'NUMBER',
          description:
            'Amount to convert. Optional — defaults to 1 if not provided.',
        },
      },
      required: ['from', 'to'],
    },
  },
];

module.exports = { toolDefinitions };
</code></pre>
<p>Vague descriptions work most of the time. The problems show up at the edges. "Does currency stuff" routes fine on a simple question. It falls apart on anything ambiguous. "Get the current exchange rate between two currencies. Use this when the user asks about currency conversion." holds up. The extra words cost basically nothing. Debugging bad routing costs a lot more.</p>
<h2 id="heading-implementing-the-tool-functions">Implementing the Tool Functions</h2>
<p>These are the actual functions that run when the model requests them. Each receives the arguments the model decided to pass, does real work, and returns a plain JavaScript object.</p>
<pre><code class="language-js">// src/functions.js

async function get_weather({ city }) {
  // Open-Meteo uses a two-step approach: geocode the city first, then fetch weather.
  // Both APIs are free with no key required.
  const geoUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&amp;count=1`;
  const geoRes  = await fetch(geoUrl);
  const geoData = await geoRes.json();

  if (!geoData.results?.length) {
    return { error: `City not found: ${city}` };
  }

  const { latitude, longitude, name, country } = geoData.results[0];

  const weatherUrl =
    `https://api.open-meteo.com/v1/forecast` +
    `?latitude=${latitude}&amp;longitude=${longitude}` +
    `&amp;current=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code`;

  const weatherRes  = await fetch(weatherUrl);
  const weatherData = await weatherRes.json();
  const current     = weatherData.current;

  return {
    city:        `${name}, ${country}`,
    temperature: `${current.temperature_2m}°C`,
    humidity:    `${current.relative_humidity_2m}%`,
    wind_speed:  `${current.wind_speed_10m} km/h`,
  };
}

function calculate({ expression }) {
  try {
    // Strip anything that is not a number or basic operator before eval.
    // This is not a complete sandbox — use a proper math parser like mathjs
    // in production if expressions come from untrusted users.
    const safe = expression.replace(/[^0-9+\-*/.() %]/g, '');
    if (!safe.trim()) return { error: 'Invalid or empty expression' };

    const result = Function('"use strict"; return (' + safe + ')')();
    return { expression, result };
  } catch {
    return { error: `Could not evaluate: ${expression}` };
  }
}

async function get_exchange_rate({ from, to, amount = 1 }) {
  const url  = `https://api.frankfurter.app/latest?from=${from.toUpperCase()}&amp;to=${to.toUpperCase()}`;
  const res  = await fetch(url);
  const data = await res.json();

  if (data.error) return { error: data.error };

  const rate      = data.rates[to.toUpperCase()];
  if (!rate) return { error: `No rate found for ${from} → ${to}` };

  const converted = parseFloat((amount * rate).toFixed(4));

  return { from: from.toUpperCase(), to: to.toUpperCase(), rate, amount, converted };
}

module.exports = { get_weather, calculate, get_exchange_rate };
</code></pre>
<p>There are a few things worth pointing out here.</p>
<p>Open-Meteo uses geocoding before the weather fetch. Passing latitude and longitude directly to the weather endpoint is more reliable than a city name string, and the geocoding API handles misspellings reasonably well. There are two fetch calls, but that's the trade-off for accuracy.</p>
<p>The <code>calculate</code> function strips everything except digits and operators before evaluation. It narrows the injection risk, but it's not a real sandbox. If you expose this over HTTP with anonymous users, use a proper math parser like <a href="https://mathjs.org">mathjs</a>.</p>
<p>Frankfurter converts currency codes to uppercase before the request. Users will type "usd" or "Usd" and both should work. The API is case-sensitive on its end.</p>
<h2 id="heading-building-the-agentic-loop">Building the Agentic Loop</h2>
<p>This is the file everything else supports. The loop itself is about 20 lines. The rest is logging and error handling.</p>
<pre><code class="language-js">// src/agent.js
const { GoogleGenerativeAI } = require('@google/generative-ai');
const { toolDefinitions }    = require('./tools');
const { get_weather, calculate, get_exchange_rate } = require('./functions');

const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);

// Map tool names to handler functions
const toolHandlers = { get_weather, calculate, get_exchange_rate };

async function runAgent(userMessage) {
  const model = genAI.getGenerativeModel({
    model: process.env.GEMINI_MODEL || 'gemini-2.0-flash',
    tools: [{ functionDeclarations: toolDefinitions }],
  });

  const chat = model.startChat();

  console.log(`\nUser: ${userMessage}`);
  console.log('---');

  let response = await chat.sendMessage(userMessage);
  let iterations = 0;
  const MAX_ITERATIONS = 10; // safety cap against infinite loops

  // Agentic loop
  while (iterations &lt; MAX_ITERATIONS) {
    iterations++;
    const calls = response.response.functionCalls();

    // No tool calls — model is done, return the answer
    if (!calls || calls.length === 0) break;

    // Run all requested tools, collect results
    const toolResults = await Promise.allSettled(
      calls.map(async (call) =&gt; {
        console.log(`Calling tool: ${call.name}(${JSON.stringify(call.args)})`);

        const handler = toolHandlers[call.name];

        if (!handler) {
          return {
            functionResponse: {
              name:     call.name,
              response: { error: `Unknown tool: ${call.name}` },
            },
          };
        }

        try {
          const result = await handler(call.args);
          console.log(`Tool result: ${JSON.stringify(result)}`);
          return {
            functionResponse: {
              name:     call.name,
              response: result,
            },
          };
        } catch (err) {
          return {
            functionResponse: {
              name:     call.name,
              response: { error: err.message },
            },
          };
        }
      })
    );

    // Extract values from allSettled (fulfilled only — errors already caught above)
    const parts = toolResults
      .filter(r =&gt; r.status === 'fulfilled')
      .map(r =&gt; r.value);

    // Send all results back to the model in one message
    response = await chat.sendMessage(parts);
  }

  return response.response.text();
}

module.exports = { runAgent };
</code></pre>
<p>The <code>MAX_ITERATIONS</code> cap isn't paranoia. A model can get into a loop if a tool keeps returning an error and the model keeps retrying. 10 iterations is more than enough for any real query. A complex multi-tool question typically resolves in two or three turns.</p>
<p><code>Promise.allSettled</code> runs all requested tools in parallel rather than sequentially. When the model requests both weather and exchange rate in the same response, they fetch simultaneously. Individual tool failures get caught inside the map rather than letting one failure abort the others.</p>
<p>The logging is intentional. When you're building and testing an agent, watching the tool calls happen in real time tells you whether the routing is working. Production code would route these to a structured logger instead of console output, but the information is the same.</p>
<h2 id="heading-the-cli-entry-point">The CLI Entry Point</h2>
<p><code>require('dotenv').config()</code> runs first so your API key loads before anything else. After that it's a standard readline loop: each line you type goes to <code>runAgent</code>, the response prints, and the loop waits for the next input.</p>
<pre><code class="language-js">// src/index.js
require('dotenv').config();
const readline     = require('readline');
const { runAgent } = require('./agent');

const rl = readline.createInterface({
  input:  process.stdin,
  output: process.stdout,
});

function ask(prompt) {
  return new Promise(resolve =&gt; rl.question(prompt, resolve));
}

async function main() {
  console.log('Gemini Agent — type your question, or "exit" to quit\n');

  while (true) {
    const input = await ask('You: ');
    if (input.toLowerCase() === 'exit') break;
    if (!input.trim()) continue;

    try {
      const answer = await runAgent(input);
      console.log(`\nAgent: ${answer}\n`);
    } catch (err) {
      console.error(`Error: ${err.message}`);
    }
  }

  rl.close();
}

main();
</code></pre>
<h2 id="heading-adding-an-express-http-server">Adding an Express HTTP Server</h2>
<p>The CLI is useful for testing. For integrating into an app, you need an HTTP endpoint.</p>
<pre><code class="language-js">// src/server.js
require('dotenv').config();
const express      = require('express');
const { runAgent } = require('./agent');

const app  = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

app.post('/agent', async (req, res) =&gt; {
  const { message } = req.body;

  if (!message || typeof message !== 'string') {
    return res.status(400).json({ error: 'message is required and must be a string' });
  }

  try {
    const answer = await runAgent(message);
    res.json({ answer });
  } catch (err) {
    console.error('[agent error]', err.message);
    res.status(500).json({ error: 'Agent failed to process the request' });
  }
});

app.listen(PORT, () =&gt; {
  console.log(`Agent server running on http://localhost:${PORT}`);
});
</code></pre>
<p>Start it:</p>
<pre><code class="language-bash">node src/server.js
</code></pre>
<p>Call it:</p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/agent \
  -H "Content-Type: application/json" \
  -d '{"message": "What is the weather in Paris?"}'
</code></pre>
<p>Response:</p>
<pre><code class="language-json">{
  "answer": "The current weather in Paris, France is 19°C with 65% humidity and wind speeds of 12 km/h."
}
</code></pre>
<p>The POST body is a plain <code>{ message }</code> object. The response is a plain <code>{ answer }</code> string. The agent handles the rest.</p>
<h2 id="heading-testing-the-agent">Testing the Agent</h2>
<p>Start the CLI:</p>
<pre><code class="language-bash">node src/index.js
</code></pre>
<p>The <code>You:</code> prompt comes from the readline in <code>index.js</code>. The <code>User:</code> line and <code>---</code> separator are logged by <code>agent.js</code> at the start of each run. This is the same logging discussed in the agentic loop section.</p>
<p>Single tool — weather:</p>
<pre><code class="language-plaintext">You: What's the weather in Tokyo?

User: What's the weather in Tokyo?
---
Calling tool: get_weather({"city":"Tokyo"})
Tool result: {"city":"Tokyo, JP","temperature":"31°C","humidity":"72%","wind_speed":"8 km/h"}

Agent: The current weather in Tokyo, Japan is 31°C with 72% humidity and wind speeds of 8 km/h.
</code></pre>
<p>Two tools — chained reasoning:</p>
<pre><code class="language-plaintext">You: What is the weather in Tokyo? If it's above 20°C, convert 10000 JPY to EUR.

User: What is the weather in Tokyo? If it's above 20°C, convert 10000 JPY to EUR.
---
Calling tool: get_weather({"city":"Tokyo"})
Tool result: {"city":"Tokyo, JP","temperature":"31°C","humidity":"72%","wind_speed":"8 km/h"}

Calling tool: get_exchange_rate({"from":"JPY","to":"EUR","amount":10000})
Tool result: {"from":"JPY","to":"EUR","rate":0.006,"amount":10000,"converted":60.0}

Agent: The current temperature in Tokyo is 31°C, which is above 20°C.
Converting 10,000 JPY to EUR at the current exchange rate gives approximately 60.00 EUR.
</code></pre>
<p>Notice what happened: the model called <code>get_weather</code>, read the result (31°C), applied the conditional logic from the user's question on its own, and then called <code>get_exchange_rate</code>. You didn't write any of that branching logic. The model handled it from the description alone.</p>
<p>Calculator:</p>
<pre><code class="language-plaintext">You: How much is 18% tip on a $47.50 restaurant bill?

User: How much is 18% tip on a $47.50 restaurant bill?
---
Calling tool: calculate({"expression":"47.50 * 0.18"})
Tool result: {"expression":"47.50 * 0.18","result":8.55}

Agent: An 18% tip on a $47.50 bill is $8.55, making your total $56.05.
</code></pre>
<p>Three tools in one query:</p>
<pre><code class="language-plaintext">You: What's the weather in London and Berlin? And what is 250 GBP in EUR?

User: What's the weather in London and Berlin? And what is 250 GBP in EUR?
---
Calling tool: get_weather({"city":"London"})
Calling tool: get_weather({"city":"Berlin"})
Calling tool: get_exchange_rate({"from":"GBP","to":"EUR","amount":250})
Tool result: {"city":"London, GB","temperature":"16°C","humidity":"78%","wind_speed":"20 km/h"}
Tool result: {"city":"Berlin, DE","temperature":"22°C","humidity":"55%","wind_speed":"14 km/h"}
Tool result: {"from":"GBP","to":"EUR","rate":1.17,"amount":250,"converted":292.5}

Agent: London is currently 16°C with 78% humidity and 20 km/h winds.
Berlin is warmer at 22°C with 55% humidity and lighter winds of 14 km/h.
250 GBP converts to approximately 292.50 EUR at the current exchange rate.
</code></pre>
<p>All three tools ran in parallel. <code>Promise.allSettled</code> is why. A sequential loop would have made three serial network requests. Parallel gives you the same result in roughly the time of the slowest single request.</p>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<p>Here are a few common issues you might encounter, and how to fix them:</p>
<h3 id="heading-1-404-not-found-modelsgemini-15-flash-is-not-found-for-api-version-v1beta">1. <code>[404 Not Found] models/gemini-1.5-flash is not found for API version v1beta</code></h3>
<p>The model name is outdated. Google deprecates older aliases over time. Swap it out for <code>gemini-2.0-flash</code> in <code>agent.js</code>. To check what models your key can actually access, run:</p>
<pre><code class="language-bash">node -e "
const { GoogleGenerativeAI } = require('@google/generative-ai');
require('dotenv').config();
const g = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
g.listModels().then(r =&gt; r.models.forEach(m =&gt; console.log(m.name)));
"
</code></pre>
<h3 id="heading-2-429-too-many-requests-you-exceeded-your-current-quota">2. <code>[429 Too Many Requests] You exceeded your current quota</code></h3>
<p>The <code>gemini-2.0-flash</code> free tier caps you at 1,500 requests a day. Hit that and every call returns a 429 until midnight Pacific resets the counter.</p>
<p>The error names the quota ID directly. <code>GenerateRequestsPerDayPerProjectPerModel-FreeTier</code> means you hit the daily cap. <code>GenerateRequestsPerMinutePerProjectPerModel-FreeTier</code> means the per-minute rate.</p>
<p>For the per-minute limit, the error includes a <code>retryDelay</code> field. Wait that many seconds and retry. For the daily limit, the quota is per-project. All models under the same project share it.</p>
<p>There are three ways out:</p>
<ul>
<li><p><strong>New project</strong> (fastest): head to <a href="https://aistudio.google.com">aistudio.google.com</a>, spin up a new project, grab a new API key, and swap it into <code>.env</code>. You get a fresh quota immediately.</p>
</li>
<li><p><strong>Enable billing</strong>: billing-enabled projects get much higher limits while keeping the free usage tier. Set up at <a href="https://aistudio.google.com">aistudio.google.com</a>.</p>
</li>
<li><p><strong>Wait</strong>: resets daily at midnight Pacific.</p>
</li>
</ul>
<p>Because <code>agent.js</code> reads the model name from <code>process.env.GEMINI_MODEL</code>, you can also switch models without touching code. Add this to your <code>.env</code> to test with a lighter model:</p>
<pre><code class="language-plaintext">GEMINI_MODEL=gemini-2.0-flash-lite
</code></pre>
<p>Remove the line when your quota resets and the agent goes back to <code>gemini-2.0-flash</code>.</p>
<h3 id="heading-3-error-geminiapikey-is-not-set">3. <code>Error: GEMINI_API_KEY is not set</code></h3>
<p>Nine times out of ten, <code>require('dotenv').config()</code> is either missing or buried below other requires. Drag it to the very top of <code>index.js</code>. Your <code>.env</code> also needs to live at the project root with your actual key in it, not <code>your_api_key_here</code>.</p>
<h3 id="heading-4-googlegenerativeaierror-400-invalidargument">4. <code>GoogleGenerativeAIError: 400 INVALID_ARGUMENT</code></h3>
<p>Almost always a malformed tool schema. Gemini uses uppercase type strings: <code>'OBJECT'</code>, <code>'STRING'</code>, <code>'NUMBER'</code>. JSON Schema uses lowercase. Check your <code>parameters.type</code> values.</p>
<h3 id="heading-5-model-answers-without-calling-any-tools">5. Model Answers Without Calling Any Tools</h3>
<p>The description is too vague or the user's question doesn't match well enough for the model to route it. Add more context to the description about when the tool should be used. The phrase "use this when the user asks about X" directly improves routing accuracy.</p>
<h3 id="heading-6-typeerror-fetch-is-not-a-function">6. <code>TypeError: fetch is not a function</code></h3>
<p>Node 17 and below don't have native <code>fetch</code>. It was added in Node 18. Run <code>node --version</code> to check yours.</p>
<p>If you can't upgrade, install it with <code>npm install node-fetch</code>. Every file that calls <code>fetch</code> then needs <code>const fetch = require('node-fetch')</code> as its first line.</p>
<h3 id="heading-7-tool-works-in-isolation-but-agent-loop-doesnt-call-it">7. Tool Works in Isolation but Agent Loop Doesn't Call it</h3>
<p>The name in <code>toolDefinitions</code> must exactly match the key in <code>toolHandlers</code>. Case matters in JavaScript. <code>get_Weather</code> and <code>get_weather</code> are two different things.</p>
<h3 id="heading-8-exchange-rate-returns-no-rate-found">8. Exchange Rate Returns <code>No rate found</code></h3>
<p>The currency code you passed isn't supported by frankfurter.app. The API covers ~30 major currencies. Check supported codes at <a href="https://www.frankfurter.app/docs/">frankfurter.app</a>.</p>
<h2 id="heading-what-to-build-next">What to Build Next</h2>
<p>The three tools here are a foundation. The loop works the same way regardless of how many tools you add.</p>
<p><strong>Database lookup tool:</strong> A <code>search_products</code> function that queries your PostgreSQL table turns the agent into a product assistant. Point it at your catalog, and it can answer questions about availability, pricing, and specs without you writing any routing logic.</p>
<p><strong>Write tools:</strong> <code>get_*</code> functions make the agent read-only. Add a <code>create_ticket</code> or <code>send_notification</code> function and the agent can take actions: file a support request, trigger a workflow, update a record. Once you add write tools, think carefully about <strong>which queries should require confirmation before executing</strong>.</p>
<p><strong>Memory across sessions:</strong> Right now <code>model.startChat()</code> creates a fresh conversation on every call. Pass a <code>history</code> array when starting the chat and the model remembers prior turns. Store that history in PostgreSQL or Redis keyed to the user ID, and the agent carries context across sessions.</p>
<p><strong>Streaming responses:</strong> For a UI that shows the answer as it types rather than waiting for the full response, replace <code>chat.sendMessage</code> with <code>chat.sendMessageStream</code>. The tool call loop stays the same. Only the final response delivery changes.</p>
<p><strong>Swap the model:</strong> The <code>model</code> string in <code>getGenerativeModel</code> is the only thing that pins you to Gemini 2.0 Flash. <code>gemini-2.0-flash-lite</code> is lighter and faster for simpler queries. For stronger reasoning on complex tasks, run the <code>listModels</code> script from the Troubleshooting section to find the latest available models. The function calling interface is identical across all Gemini models, so swapping takes one line.</p>
<p>The full source code for this article is on GitHub at <a href="https://github.com/ziaongit/nodejs-gemini-agent">github.com/ziaongit/nodejs-gemini-agent</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Multi-Tenant SaaS API with Node.js, RBAC, and Audit Logging ]]>
                </title>
                <description>
                    <![CDATA[ A colleague asked me to help debug what looked like a permissions issue in their SaaS project management tool. Users were seeing resources they hadn't created. I pulled up the query logs expecting som ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-multi-tenant-saas-api-with-nodejs-rbac-and-audit-logging/</link>
                <guid isPermaLink="false">6a5e8914bc397f89a942b88b</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ PostgreSQL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ backend ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Mon, 20 Jul 2026 20:46:12 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/69793fe0-fe0e-4c9c-839d-12a134f65287.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A colleague asked me to help debug what looked like a permissions issue in their SaaS project management tool. Users were seeing resources they hadn't created.</p>
<p>I pulled up the query logs expecting something subtle. It was not. The list endpoint had no <code>tenant_id</code> filter at all. Every tenant in the database could read every other tenant's projects. The application never threw an error. It just returned whatever was there.</p>
<p>Missing tenant filters don't throw errors. They return the wrong data without any complaint, and nothing in your logs will flag it. I've seen this run in production for weeks before a support ticket pointed anyone at the query logs.</p>
<p>When it does surface, who finds it first matters a lot. A customer noticing it is bad. A compliance auditor noticing it during a SOC 2 review is a different kind of problem.</p>
<p>Isolation built in from the start is a day of work. The time I spent helping a team retrofit it after a compliance review was considerably longer than that, and involved more customer emails than anyone wanted to write.</p>
<p>The stack is Node.js with PostgreSQL. CRUD is the easy part. Tenant isolation, RBAC, and audit logging take more care, and where those checks run in the stack matters. I put all three in middleware, before any route handler fires. A handler that never calls the isolation logic directly can't accidentally skip it.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js 18+</p>
</li>
<li><p>PostgreSQL 14+</p>
</li>
<li><p>Basic knowledge of Express.js and JWT</p>
</li>
</ul>
<h2 id="heading-what-we-will-build">What We Will Build</h2>
<p>A multi-tenant Express REST API that enforces:</p>
<ol>
<li><p><strong>Tenant isolation:</strong> every database query scopes to the <code>tenant_id</code> from the verified JWT. The client can't influence which tenant the query runs against.</p>
</li>
<li><p><strong>RBAC:</strong> four roles, each with a numeric level (SuperAdmin is highest, Viewer lowest). Middleware checks the level before the handler runs.</p>
</li>
<li><p><strong>Audit logging:</strong> any write or sensitive read appends a row to the audit table. The app can't modify those rows afterward. The database enforces this directly. If a bug in the app tries to UPDATE an audit row, the database refuses it. Application-level enforcement alone can't give you that guarantee.</p>
</li>
<li><p><strong>Per-tenant rate limiting:</strong> request counts in Redis, keyed to the tenant. I've seen IP-based limiting break an enterprise rollout when fifty users came through a single corporate proxy.</p>
</li>
<li><p><strong>Tenant isolation tests:</strong> a dedicated test file that proves cross-tenant data can't leak. Wire it into CI and it catches broken isolation before it ships.</p>
</li>
</ol>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-how-multi-tenancy-works">How Multi-Tenancy Works</a></p>
</li>
<li><p><a href="#heading-architecture-overview">Architecture Overview</a></p>
</li>
<li><p><a href="#heading-database-schema-design">Database Schema Design</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-jwt-design-for-multi-tenancy">JWT Design for Multi-Tenancy</a></p>
</li>
<li><p><a href="#heading-auth-and-rbac-middleware">Auth and RBAC Middleware</a></p>
</li>
<li><p><a href="#heading-the-tenant-safe-repository-layer">The Tenant-Safe Repository Layer</a></p>
</li>
<li><p><a href="#heading-audit-logging-service">Audit Logging Service</a></p>
</li>
<li><p><a href="#heading-per-tenant-rate-limiting">Per-Tenant Rate Limiting</a></p>
</li>
<li><p><a href="#heading-building-the-routes">Building the Routes</a></p>
</li>
<li><p><a href="#heading-testing-tenant-isolation">Testing Tenant Isolation</a></p>
</li>
<li><p><a href="#heading-troubleshooting">Troubleshooting</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-how-multi-tenancy-works">How Multi-Tenancy Works</h2>
<p>This tutorial uses a <strong>shared database with row-level isolation</strong>: a <code>tenant_id</code> column on every table, a filter on every query. The database holds everyone's data together. The application decides what each tenant can see.</p>
<p>Two other approaches exist: schema-per-tenant and database-per-tenant. I've talked to teams on schema-per-tenant who ended up spending more engineering time on migration tooling than on their actual product. Database-per-tenant gives stronger guarantees but a connection pool that balloons with every new customer signup.</p>
<p>Neither scales cheaply. Row-level isolation scales further than most teams expect. The ones I know who moved off it did so years in, usually under specific regulatory pressure, not because the approach stopped working.</p>
<p>The one thing in this design that can't be optional: <code>tenant_id</code> <strong>must always come from the verified JWT.</strong> Not from the request body, not from the URL. Users control what they put in both of those. They don't control what gets signed into a JWT on your server.</p>
<h2 id="heading-architecture-overview">Architecture Overview</h2>
<pre><code class="language-plaintext">HTTP Request
     │
     ▼
┌─────────────────────────────────────────┐
│           Express Middleware Stack       │
│                                         │
│  1. Rate Limiter (per tenant_id)        │
│  2. Auth Middleware (verify JWT)        │
│     └─► Extracts: userId, tenantId,    │
│          role, permissions              │
│  3. RBAC Middleware (check role)        │
└──────────────┬──────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────┐
│           Route Handler                  │
│                                         │
│  1. Call Repository (tenant-safe query) │
│  2. Call Audit Service (fire &amp; forget)  │
│  3. Return response                     │
└──────────────┬──────────────────────────┘
               │
     ┌─────────┴──────────┐
     ▼                    ▼
┌─────────┐        ┌────────────┐
│ Projects│        │ Audit Logs │
│  Table  │        │   Table    │
│(+tenant)│        │(append only│
└─────────┘        └────────────┘
</code></pre>
<p>Rate limiting, auth, and RBAC all run before any handler sees the request. Writes pass through the audit service. The repository takes <code>tenantId</code> from <code>req.user</code> and the handler never touches tenant scoping directly, so there's no path around it.</p>
<h2 id="heading-database-schema-design">Database Schema Design</h2>
<pre><code class="language-sql">-- Tenants table
CREATE TABLE tenants (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name        VARCHAR(255) NOT NULL,
  plan        VARCHAR(50) NOT NULL DEFAULT 'free', -- 'free', 'pro', 'enterprise'
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Users table
CREATE TABLE users (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  email       VARCHAR(255) NOT NULL,
  role        VARCHAR(50) NOT NULL DEFAULT 'Member', -- 'SuperAdmin','TenantAdmin','Member','Viewer'
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE(tenant_id, email)
);

CREATE INDEX idx_users_tenant ON users(tenant_id);

-- Projects table (example resource — replace with your domain entity)
CREATE TABLE projects (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  name        VARCHAR(255) NOT NULL,
  description TEXT,
  created_by  UUID NOT NULL REFERENCES users(id),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_projects_tenant ON projects(tenant_id);

-- Audit log table (append-only — never UPDATE or DELETE rows here)
CREATE TABLE audit_logs (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL,
  user_id     UUID NOT NULL,
  user_email  TEXT NOT NULL,
  user_role   TEXT NOT NULL,        -- role at time of action
  action      TEXT NOT NULL,        -- 'CREATE', 'UPDATE', 'DELETE', 'VIEW'
  resource    TEXT NOT NULL,        -- table name
  resource_id TEXT,
  old_values  JSONB,
  new_values  JSONB,
  ip_address  INET,
  user_agent  TEXT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_audit_tenant ON audit_logs(tenant_id);
CREATE INDEX idx_audit_created ON audit_logs(created_at DESC);

-- Protect audit log at database level
-- Use a DO block so this runs safely in Docker where app_user is the superuser
DO $$
BEGIN
  IF current_user &lt;&gt; 'app_user' THEN
    REVOKE DELETE, UPDATE ON audit_logs FROM app_user;
  END IF;
END $$;
</code></pre>
<p>The <code>REVOKE</code> matters. Application bugs happen. If something in your codebase accidentally tries to UPDATE an audit row, you want the database to refuse it outright, not silently comply.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<pre><code class="language-bash">mkdir nodejs-multitenant-saas-api
cd nodejs-multitenant-saas-api
npm init -y
npm install express pg jsonwebtoken bcryptjs express-rate-limit rate-limit-redis ioredis dotenv
npm install --save-dev jest supertest
</code></pre>
<h3 id="heading-starting-postgresql-and-redis-with-docker">Starting PostgreSQL and Redis with Docker</h3>
<p>Skip the local installs. One <code>docker-compose.yml</code> in the project root brings up both PostgreSQL and Redis:</p>
<pre><code class="language-yaml">services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: saas_api
      POSTGRES_USER: app_user
      POSTGRES_PASSWORD: app_password
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./schema.sql:/docker-entrypoint-initdb.d/01_schema.sql

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  postgres_data:
</code></pre>
<p>That <code>schema.sql</code> mount runs your SQL automatically when the container first starts. No psql required.</p>
<pre><code class="language-bash">docker compose up -d
</code></pre>
<p><code>.env</code> in the project root:</p>
<pre><code class="language-plaintext">DATABASE_URL=postgresql://app_user:app_password@localhost:5432/saas_api
REDIS_URL=redis://localhost:6379
JWT_SECRET=your_random_secret_here
PORT=3000
NODE_ENV=development
</code></pre>
<p>Don't type a <code>JWT_SECRET</code> by hand. Run this to generate one:</p>
<pre><code class="language-bash">node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
</code></pre>
<p>File structure:</p>
<pre><code class="language-plaintext">nodejs-multitenant-saas-api/
├── src/
│   ├── middleware/
│   │   ├── auth.js          # JWT verification + tenant extraction
│   │   ├── rbac.js          # Role enforcement
│   │   └── rateLimiter.js   # Per-tenant rate limiting
│   ├── services/
│   │   └── auditService.js  # Append-only audit logger
│   ├── repositories/
│   │   └── projectRepo.js   # Tenant-safe DB queries
│   ├── routes/
│   │   └── projects.js      # Route handlers
│   └── utils/
│       └── token.js         # JWT token generation
├── db/
│   ├── index.js             # PostgreSQL pool
│   └── redis.js             # Redis client
├── docker-compose.yml
├── app.js
├── server.js
└── tests/
    └── tenantIsolation.test.js
</code></pre>
<h3 id="heading-boilerplate-files">Boilerplate Files</h3>
<p>There are four files the tutorial doesn't cover in detail, but the test file needs all of them to run:</p>
<pre><code class="language-javascript">// db/index.js
const { Pool } = require('pg');

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

pool.on('error', (err) =&gt; console.error('PostgreSQL error:', err.message));

module.exports = { pool };
</code></pre>
<pre><code class="language-javascript">// db/redis.js
const Redis = require('ioredis');

const redisClient = new Redis(process.env.REDIS_URL);

redisClient.on('error', (err) =&gt; console.error('Redis error:', err.message));

module.exports = { redisClient };
</code></pre>
<pre><code class="language-javascript">// app.js
require('dotenv').config();
const express = require('express');
const projectsRouter = require('./src/routes/projects');

const app = express();
app.use(express.json());

app.use('/api/projects', projectsRouter);

// Global error handler — must have 4 parameters to be recognised by Express
app.use((err, req, res, next) =&gt; {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal server error' });
});

module.exports = app;
</code></pre>
<pre><code class="language-javascript">// server.js
const app = require('./app');

const PORT = process.env.PORT || 3000;
app.listen(PORT, () =&gt; console.log(`Server running on port ${PORT}`));
</code></pre>
<p><code>bcryptjs</code> is included for a login endpoint with proper password hashing. That part isn't covered here, but the GitHub repo has a working <code>/api/auth/login</code> example.</p>
<h2 id="heading-jwt-design-for-multi-tenancy">JWT Design for Multi-Tenancy</h2>
<p>Both <code>tenantId</code> and <code>role</code> go into the JWT payload. Everything downstream reads from these two fields. Get them wrong, and nothing behaves correctly.</p>
<pre><code class="language-javascript">// Example JWT payload
{
  "userId": "usr_abc123",
  "tenantId": "ten_xyz789",
  "email": "alice@acme.com",
  "role": "TenantAdmin",
  "iat": 1720000000,
  "exp": 1720086400
}
</code></pre>
<p>The roles in order of privilege:</p>
<ul>
<li><p><strong>SuperAdmin:</strong> cross-tenant access for your internal team only</p>
</li>
<li><p><strong>TenantAdmin:</strong> full access within their tenant</p>
</li>
<li><p><strong>Member:</strong> read and write within their tenant</p>
</li>
<li><p><strong>Viewer:</strong> read-only within their tenant</p>
</li>
</ul>
<p>Generate a token (used for testing and your auth endpoint):</p>
<pre><code class="language-javascript">// src/utils/token.js
const jwt = require('jsonwebtoken');

function generateToken({ userId, tenantId, email, role }) {
  return jwt.sign(
    { userId, tenantId, email, role },
    process.env.JWT_SECRET,
    { expiresIn: '24h' }
  );
}

module.exports = { generateToken };
</code></pre>
<h2 id="heading-auth-and-rbac-middleware">Auth and RBAC Middleware</h2>
<p>The auth middleware does two things: verifies the JWT signature and extracts the tenant context into <code>req.user</code>.</p>
<p>That second part is what the entire system depends on. Every query downstream reads <code>req.user.tenantId</code>. The client has no say in what that value is. They send a token the server signed, and the server reads back what it put in.</p>
<pre><code class="language-javascript">// src/middleware/auth.js
const jwt = require('jsonwebtoken');

function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or malformed Authorization header' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // tenantId always comes from the verified token — never req.body or req.params
    req.user = {
      userId:   decoded.userId,
      tenantId: decoded.tenantId,
      email:    decoded.email,
      role:     decoded.role,
    };

    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

module.exports = { authMiddleware };
</code></pre>
<p>The RBAC middleware is separate from auth by design. Auth runs on every route. Role enforcement only applies where a minimum role is required. You pass the allowed roles to <code>requireRole()</code> and it compares the user's level against the hierarchy. A Viewer trying to delete something hits the 403 before the handler ever runs.</p>
<pre><code class="language-javascript">// src/middleware/rbac.js
const ROLE_HIERARCHY = {
  SuperAdmin:   4,
  TenantAdmin:  3,
  Member:       2,
  Viewer:       1,
};

// requireRole('TenantAdmin') — user must be TenantAdmin or higher
function requireRole(...roles) {
  return (req, res, next) =&gt; {
    const userLevel = ROLE_HIERARCHY[req.user?.role] ?? 0;
    const requiredLevel = Math.min(...roles.map(r =&gt; ROLE_HIERARCHY[r] ?? 999));

    if (userLevel &lt; requiredLevel) {
      return res.status(403).json({
        error: 'Insufficient permissions',
        required: roles,
        current: req.user?.role,
      });
    }

    next();
  };
}

module.exports = { requireRole };
</code></pre>
<h2 id="heading-the-tenant-safe-repository-layer">The Tenant-Safe Repository Layer</h2>
<p>Isolation lives here. Every function takes <code>tenantId</code> as a required argument, pulled from <code>req.user</code> by the handler. There's no way to call these without providing a tenant scope. I've watched teams try to handle this with a URL parameter instead (<code>GET /api/projects?tenantId=xyz</code>) and call it isolated. It is not. Any client sends whatever it wants in a query string.</p>
<pre><code class="language-javascript">// src/repositories/projectRepo.js
const { pool } = require('../../db');

// List all projects for a tenant — tenantId is ALWAYS from the JWT
async function listProjects(tenantId) {
  const result = await pool.query(
    `SELECT id, name, description, created_by, created_at
     FROM projects
     WHERE tenant_id = $1
     ORDER BY created_at DESC`,
    [tenantId]
  );
  return result.rows;
}

// Get a single project — returns null if it belongs to a different tenant
// NOTE: Returns 404 (not 403) intentionally — don't reveal the resource exists
async function getProject(id, tenantId) {
  const result = await pool.query(
    `SELECT id, name, description, created_by, created_at
     FROM projects
     WHERE id = $1 AND tenant_id = $2`,
    [id, tenantId]
  );
  return result.rows[0] || null;
}

async function createProject({ tenantId, name, description, createdBy }) {
  const result = await pool.query(
    `INSERT INTO projects (tenant_id, name, description, created_by)
     VALUES ($1, $2, $3, $4)
     RETURNING *`,
    [tenantId, name, description, createdBy]
  );
  return result.rows[0];
}

async function updateProject(id, tenantId, updates) {
  const result = await pool.query(
    `UPDATE projects
     SET name = COALESCE($3, name),
         description = COALESCE($4, description),
         updated_at = NOW()
     WHERE id = $1 AND tenant_id = $2
     RETURNING *`,
    [id, tenantId, updates.name, updates.description]
  );
  return result.rows[0] || null;
}

async function deleteProject(id, tenantId) {
  const result = await pool.query(
    `DELETE FROM projects WHERE id = $1 AND tenant_id = $2 RETURNING id`,
    [id, tenantId]
  );
  return result.rows[0] || null;
}

module.exports = { listProjects, getProject, createProject, updateProject, deleteProject };
</code></pre>
<p>Notice what <code>getProject</code> does when Tenant A tries to fetch a Tenant B resource. The query runs with Tenant A's <code>tenantId</code>. The condition <code>id = $1 AND tenant_id = $2</code> matches nothing, <code>null</code> comes back, and the handler sends a <code>404</code>. Not a <code>403</code>. A 403 tells the caller the resource exists, but they can't access it, which is information they shouldn't have.</p>
<h2 id="heading-audit-logging-service">Audit Logging Service</h2>
<pre><code class="language-javascript">// src/services/auditService.js
const { pool } = require('../../db');

async function log({
  tenantId,
  userId,
  userEmail,
  userRole,          // role at time of action — roles change, log should not
  action,            // 'CREATE' | 'UPDATE' | 'DELETE' | 'VIEW'
  resource,          // table name
  resourceId = null,
  oldValues = null,
  newValues = null,
  ipAddress = null,
  userAgent = null,
}) {
  const query = `
    INSERT INTO audit_logs
      (tenant_id, user_id, user_email, user_role, action, resource,
       resource_id, old_values, new_values, ip_address, user_agent)
    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
  `;

  const values = [
    tenantId, userId, userEmail, userRole, action, resource,
    resourceId,
    oldValues  ? JSON.stringify(oldValues)  : null,
    newValues  ? JSON.stringify(newValues)  : null,
    ipAddress,
    userAgent,
  ];

  // Fire-and-forget — audit logging must never block or fail a user request
  pool.query(query, values).catch((err) =&gt; {
    console.error('[AuditService] Failed to write log:', err.message);
  });
}

module.exports = { log };
</code></pre>
<p>Capturing <code>userRole</code> at write time matters more than it looks. User roles change after the fact: someone gets demoted, a permission is revoked. If the log only records the user ID, you lose the context of what privilege they held when the action happened. Store the role at the time of the action, and you always know.</p>
<h2 id="heading-per-tenant-rate-limiting">Per-Tenant Rate Limiting</h2>
<p>IP-based rate limiting breaks down in SaaS. A corporate customer might route hundreds of users through a single NAT gateway, sharing one IP address. One heavy tenant throttles everyone else on that address.</p>
<p>I've watched teams discover this the hard way when an enterprise customer suddenly floods the API, and their other tenants start getting 429s with no explanation. Scope limits to <code>tenant_id</code> instead.</p>
<pre><code class="language-javascript">// src/middleware/rateLimiter.js
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
const { redisClient } = require('../../db/redis');

// Rate limits by plan — extend as needed
const PLAN_LIMITS = {
  free:       { max: 100,  windowMs: 15 * 60 * 1000 }, // 100 req / 15 min
  pro:        { max: 500,  windowMs: 15 * 60 * 1000 }, // 500 req / 15 min
  enterprise: { max: 2000, windowMs: 15 * 60 * 1000 }, // 2000 req / 15 min
};

function createTenantRateLimiter(plan = 'free') {
  const limits = PLAN_LIMITS[plan] || PLAN_LIMITS.free;

  return rateLimit({
    windowMs: limits.windowMs,
    max: limits.max,
    // Key = tenant_id from verified JWT — NOT the IP address
    keyGenerator: (req) =&gt; `tenant:${req.user?.tenantId || req.ip}`,
    store: new RedisStore({
      sendCommand: (...args) =&gt; redisClient.call(...args),
    }),
    handler: (req, res) =&gt; {
      res.status(429).json({
        error: 'Too many requests',
        retryAfter: Math.ceil(limits.windowMs / 1000),
      });
    },
  });
}

// Default limiter for all API routes
const defaultLimiter = createTenantRateLimiter('free');

module.exports = { defaultLimiter, createTenantRateLimiter };
</code></pre>
<h2 id="heading-building-the-routes">Building the Routes</h2>
<p>This is where everything connects. Auth and rate limiting apply to the whole router. Role checks go on individual routes. The audit log fires after every write. <code>tenantId</code> never comes from the request body or URL. <code>req.user.tenantId</code> is the only source, set by the auth middleware from the verified token, so there's no path around it.</p>
<p>One practical detail for Express 4: it doesn't catch async errors automatically. Every handler wraps its logic in try/catch and passes failures to <code>next(err)</code>. Skip that and an unhandled promise rejection returns a blank 500 with no log entry and no audit trail. The comment at the top of the router is a reminder that the pattern is intentional.</p>
<pre><code class="language-javascript">// src/routes/projects.js
const express = require('express');
const { authMiddleware }  = require('../middleware/auth');
const { requireRole }     = require('../middleware/rbac');
const { defaultLimiter }  = require('../middleware/rateLimiter');
const audit               = require('../services/auditService');
const repo                = require('../repositories/projectRepo');

const router = express.Router();

// All routes require authentication
router.use(authMiddleware);
router.use(defaultLimiter);

// Express 4 does not catch async errors automatically.
// Every handler must wrap await calls in try/catch and pass errors to next().
// Without this, an unhandled promise rejection silently returns 500
// with no useful message and no audit log entry.

// GET /api/projects — list all (Viewer and above)
router.get('/', async (req, res, next) =&gt; {
  try {
    const projects = await repo.listProjects(req.user.tenantId);

    audit.log({
      tenantId:   req.user.tenantId,
      userId:     req.user.userId,
      userEmail:  req.user.email,
      userRole:   req.user.role,
      action:     'VIEW',
      resource:   'projects',
      ipAddress:  req.ip,
      userAgent:  req.headers['user-agent'],
    });

    res.json(projects);
  } catch (err) {
    next(err);
  }
});

// GET /api/projects/:id — single project (Viewer and above)
router.get('/:id', async (req, res, next) =&gt; {
  try {
    const project = await repo.getProject(req.params.id, req.user.tenantId);
    if (!project) return res.status(404).json({ error: 'Not found' });
    res.json(project);
  } catch (err) {
    next(err);
  }
});

// POST /api/projects — create (Member and above)
router.post('/', requireRole('Member', 'TenantAdmin', 'SuperAdmin'), async (req, res, next) =&gt; {
  try {
    const { name, description } = req.body;
    if (!name) return res.status(400).json({ error: 'name is required' });

    const project = await repo.createProject({
      tenantId:    req.user.tenantId,
      name,
      description,
      createdBy:   req.user.userId,
    });

    audit.log({
      tenantId:    req.user.tenantId,
      userId:      req.user.userId,
      userEmail:   req.user.email,
      userRole:    req.user.role,
      action:      'CREATE',
      resource:    'projects',
      resourceId:  project.id,
      newValues:   project,
      ipAddress:   req.ip,
      userAgent:   req.headers['user-agent'],
    });

    res.status(201).json(project);
  } catch (err) {
    next(err);
  }
});

// PUT /api/projects/:id — update (Member and above)
router.put('/:id', requireRole('Member', 'TenantAdmin', 'SuperAdmin'), async (req, res, next) =&gt; {
  try {
    const oldProject = await repo.getProject(req.params.id, req.user.tenantId);
    if (!oldProject) return res.status(404).json({ error: 'Not found' });

    const updated = await repo.updateProject(req.params.id, req.user.tenantId, req.body);

    audit.log({
      tenantId:    req.user.tenantId,
      userId:      req.user.userId,
      userEmail:   req.user.email,
      userRole:    req.user.role,
      action:      'UPDATE',
      resource:    'projects',
      resourceId:  req.params.id,
      oldValues:   oldProject,
      newValues:   updated,
      ipAddress:   req.ip,
      userAgent:   req.headers['user-agent'],
    });

    res.json(updated);
  } catch (err) {
    next(err);
  }
});

// DELETE /api/projects/:id — TenantAdmin and above only
router.delete('/:id', requireRole('TenantAdmin', 'SuperAdmin'), async (req, res, next) =&gt; {
  try {
    const project = await repo.getProject(req.params.id, req.user.tenantId);
    if (!project) return res.status(404).json({ error: 'Not found' });

    await repo.deleteProject(req.params.id, req.user.tenantId);

    audit.log({
      tenantId:    req.user.tenantId,
      userId:      req.user.userId,
      userEmail:   req.user.email,
      userRole:    req.user.role,
      action:      'DELETE',
      resource:    'projects',
      resourceId:  req.params.id,
      oldValues:   project,
      ipAddress:   req.ip,
      userAgent:   req.headers['user-agent'],
    });

    res.json({ deleted: true });
  } catch (err) {
    next(err);
  }
});

module.exports = router;
</code></pre>
<h2 id="heading-testing-tenant-isolation">Testing Tenant Isolation</h2>
<p>Skip the isolation tests and you're flying blind. The application keeps running, nothing throws an error, but two customers are reading each other's data.</p>
<p>I've watched this sit undetected in production for months because nothing actually broke. The wrong data just showed up quietly. Automated tests on every pull request are the only reliable way to catch it early.</p>
<pre><code class="language-javascript">// tests/tenantIsolation.test.js
require('dotenv').config();  // must be first — loads DATABASE_URL and REDIS_URL
const request = require('supertest');
const app     = require('../app');
const { generateToken } = require('../src/utils/token');
const { pool }        = require('../db');
const { redisClient } = require('../db/redis');

// Test fixture: two isolated tenants, one project in Tenant B
async function seedTestData() {
  // Clean up from any previous run to avoid unique-constraint failures
  await pool.query(`DELETE FROM projects WHERE name LIKE 'TEST-%'`);
  await pool.query(`DELETE FROM tenants WHERE name IN ('Tenant A', 'Tenant B')`);

  const tenantA = (await pool.query(
    `INSERT INTO tenants (name, plan) VALUES ('Tenant A', 'pro') RETURNING id`
  )).rows[0].id;

  const tenantB = (await pool.query(
    `INSERT INTO tenants (name, plan) VALUES ('Tenant B', 'pro') RETURNING id`
  )).rows[0].id;

  const userA = (await pool.query(
    `INSERT INTO users (tenant_id, email, role) VALUES ($1, 'usera@a.com', 'Member') RETURNING id`,
    [tenantA]
  )).rows[0].id;

  // userB owns the project in Tenant B — satisfies the created_by FK constraint
  const userB = (await pool.query(
    `INSERT INTO users (tenant_id, email, role) VALUES ($1, 'userb@b.com', 'Member') RETURNING id`,
    [tenantB]
  )).rows[0].id;

  const projectB = (await pool.query(
    `INSERT INTO projects (tenant_id, name, created_by)
     VALUES ($1, 'TEST-Secret Project', $2) RETURNING id`,
    [tenantB, userB]
  )).rows[0].id;

  return { tenantA, tenantB, userA, projectB };
}

describe('Tenant Isolation', () =&gt; {
  let data;

  beforeAll(async () =&gt; {
    data = await seedTestData();
  });

  afterAll(async () =&gt; {
    await pool.query(`DELETE FROM tenants WHERE name IN ('Tenant A', 'Tenant B')`);
    await pool.end();
    await redisClient.quit();  // close Redis connection so Jest exits cleanly
  });

  test('Tenant A user cannot read Tenant B project', async () =&gt; {
    const token = generateToken({
      userId:   data.userA,
      tenantId: data.tenantA,   // ← Tenant A token
      email:    'usera@a.com',
      role:     'Member',
    });

    const res = await request(app)
      .get(`/api/projects/${data.projectB}`)  // ← Tenant B's project ID
      .set('Authorization', `Bearer ${token}`);

    // Must be 404, not 200 or 403
    expect(res.status).toBe(404);
  });

  test('Tenant A user cannot list Tenant B projects', async () =&gt; {
    const token = generateToken({
      userId:   data.userA,
      tenantId: data.tenantA,
      email:    'usera@a.com',
      role:     'TenantAdmin',
    });

    const res = await request(app)
      .get('/api/projects')
      .set('Authorization', `Bearer ${token}`);

    expect(res.status).toBe(200);
    // Response must contain zero Tenant B projects
    const names = res.body.map(p =&gt; p.name);
    expect(names).not.toContain('TEST-Secret Project');
  });

  test('Viewer cannot delete a project', async () =&gt; {
    const token = generateToken({
      userId:   data.userA,
      tenantId: data.tenantA,
      email:    'usera@a.com',
      role:     'Viewer',         // ← Viewer role
    });

    const res = await request(app)
      .delete(`/api/projects/${data.projectB}`)
      .set('Authorization', `Bearer ${token}`);

    expect(res.status).toBe(403);
  });
});
</code></pre>
<p>Run the tests:</p>
<pre><code class="language-bash">npm test
</code></pre>
<p>Three tests, three boundaries confirmed. Wire these into CI so they run on every pull request. A future refactor that quietly drops the <code>tenant_id</code> filter will get caught before it ships.</p>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<h3 id="heading-tenant-a-can-see-tenant-bs-data">Tenant A can see Tenant B's data</h3>
<p>One query is missing the <code>AND tenant_id = $N</code> clause. Search every repository file for <code>SELECT</code> statements and check each one. It's almost always this.</p>
<h3 id="heading-403-forbidden-on-a-route-that-should-be-accessible"><code>403 Forbidden</code> on a route that should be accessible</h3>
<p>The role string in the JWT doesn't match what <code>requireRole()</code> is checking. Check the exact string in the token payload. <code>'member'</code> and <code>'Member'</code> aren't the same thing. Paste your token into jwt.io and look at the role field directly.</p>
<h3 id="heading-rate-limiter-isnt-working">Rate limiter isn't working</h3>
<p>Redis is probably not connected. Log <code>redisClient.status</code> before the server starts. If it's not <code>ready</code>, the limiter has fallen back to in-memory, which means restarts reset all counters and tenant-scoped limiting stops working.</p>
<h3 id="heading-audit-log-table-growing-very-large">Audit log table growing very large</h3>
<p>Expected behaviour. Audit tables grow, that's the point. Once it gets large, ship rows older than a year to S3 or Azure Blob and keep querying against a smaller hot table. Most compliance requirements want at least 12 months of accessible logs anyway. Just don't DELETE from the table itself.</p>
<h3 id="heading-jwtverify-throws-jsonwebtokenerror-invalid-signature"><code>jwt.verify</code> throws <code>JsonWebTokenError: invalid signature</code></h3>
<p>The secret that signed the token doesn't match <code>JWT_SECRET</code> in the environment where you're verifying it. This comes up most when switching between environments or when a second service has a different value in its <code>.env</code>. Every service that calls <code>jwt.verify</code> needs the exact same secret. Copy it across, don't retype it.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>The system you've built: row-level isolation in the repository, role checks before the handler runs, an audit table the app can't touch, and rate limits per tenant. That's the whole thing.</p>
<p>The tests are what I see dropped most often. Teams build the isolation, ship it, and never write something that actually proves cross-tenant data can't leak. Then a query gets refactored six months later and the <code>tenant_id</code> filter quietly disappears. CI catches it. Manual code review rarely does.</p>
<p>Schema-per-tenant comes up eventually if your product grows large enough. But not at the start. Row-level isolation handles more scale than most teams will ever hit, and it costs a fraction of the operational overhead.</p>
<p>The full working code is available on GitHub: <a href="https://github.com/ziaongit/nodejs-multitenant-saas-api">nodejs-multitenant-saas-api</a></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Manage Secrets Securely with Azure Key Vault in Node.js ]]>
                </title>
                <description>
                    <![CDATA[ Last year a client called me about exactly this. Someone ran git log -p on a hunch and found a .env committed two years earlier, never caught. Database password, Stripe secret, JWT signing key — all s ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-manage-secrets-securely-with-azure-key-vault-in-node-js/</link>
                <guid isPermaLink="false">6a5e27b295e748bed9510853</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Azure ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Mon, 20 Jul 2026 13:50:42 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/5491b408-9c6b-4d4d-a53e-215119fb2d97.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Last year a client called me about exactly this. Someone ran <code>git log -p</code> on a hunch and found a <code>.env</code> committed two years earlier, never caught. Database password, Stripe secret, JWT signing key — all still active. All still in production.</p>
<p>IBM's 2024 breach cost report put the average data breach at <strong>$4.88 million</strong> — and that's the average, not the worst cases.</p>
<p>Exposed credentials are consistently near the top of root causes. GitHub found over a million secrets leaked in public repos in 2023 alone, before you even count the private ones nobody ever discovered.</p>
<p>It's not a people problem. The developers I've worked with aren't careless — the architecture is just set up to fail them. A <code>.env</code> file gets committed once by accident. Credentials get copied and pasted into a Slack message to unblock a teammate. A Docker image gets published with secrets baked into a layer. A server gets shut down, and nobody rotates the credentials it was holding.</p>
<p>Azure Key Vault solves this differently. Your application fetches credentials at runtime from a centralized, encrypted service — the <code>.env</code> file stops being a liability because it stops holding anything worth stealing.</p>
<p>What you'll build is a Node.js Express API that fetches every secret from Azure Key Vault at startup. No passwords in the code. When someone quits, there's nothing in the repo to rotate. The <code>.env</code> ends up with one line — the vault name.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js 18+</p>
</li>
<li><p>An Azure account (free tier works)</p>
</li>
<li><p>Azure CLI installed and logged in (<code>az login</code>)</p>
</li>
<li><p>Basic knowledge of Express.js</p>
</li>
<li><p>Docker (optional — only needed for the local database test section)</p>
</li>
</ul>
<h2 id="heading-what-we-will-build">What We Will Build</h2>
<p>A Node.js Express API that:</p>
<ol>
<li><p>Connects to PostgreSQL using credentials fetched from Key Vault at startup</p>
</li>
<li><p>Uses Managed Identity for authentication — no client secrets or passwords anywhere</p>
</li>
<li><p>Caches secrets in memory, so Key Vault isn't called on every request</p>
</li>
<li><p>Works locally via Azure CLI auth and in production via Managed Identity — same code, zero changes</p>
</li>
</ol>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-how-the-architecture-works">How the Architecture Works</a></p>
</li>
<li><p><a href="#heading-what-is-azure-key-vault">What Is Azure Key Vault?</a></p>
</li>
<li><p><a href="#heading-set-up-the-key-vault">Set Up the Key Vault</a></p>
</li>
<li><p><a href="#heading-create-the-nodejs-project">Create the Node.js Project</a></p>
</li>
<li><p><a href="#heading-connect-to-key-vault-with-managed-identity">Connect to Key Vault with Managed Identity</a></p>
</li>
<li><p><a href="#heading-cache-secrets-at-startup">Cache Secrets at Startup</a></p>
</li>
<li><p><a href="#heading-use-secrets-in-your-express-api">Use Secrets in Your Express API</a></p>
</li>
<li><p><a href="#heading-test-locally">Test Locally</a></p>
</li>
<li><p><a href="#heading-deploy-to-azure-app-service">Deploy to Azure App Service</a></p>
</li>
<li><p><a href="#heading-grant-key-vault-access-to-the-app">Grant Key Vault Access to the App</a></p>
</li>
<li><p><a href="#heading-rotate-secrets-without-redeploying">Rotate Secrets Without Redeploying</a></p>
</li>
<li><p><a href="#heading-troubleshooting">Troubleshooting</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-how-the-architecture-works">How the Architecture Works</h2>
<p>Before writing any code, it helps to see the full picture:</p>
<pre><code class="language-plaintext"> LOCAL DEVELOPMENT
.-------------------------------------------------------.
|                                                        |
|   [Node.js App]                                        |
|        |                                               |
|        v                                               |
|   [DefaultAzureCredential] ---&gt; az login session       |
|        |                                               |
|        v                                               |
|   [Azure Key Vault]  ---&gt; Returns secrets              |
|        |                                               |
|        v                                               |
|   [In-memory cache]  ---&gt; App uses secrets at runtime  |
'-------------------------------------------------------'

 PRODUCTION (Azure)
.-------------------------------------------------------.
|                                                        |
|   [Azure App Service]                                  |
|        |                                               |
|        v                                               |
|   [DefaultAzureCredential] ---&gt; Managed Identity       |
|        |                                               |
|        v                                               |
|   [Azure Key Vault]  ---&gt; Returns secrets              |
|        |                                               |
|        v                                               |
|   [In-memory cache]  ---&gt; App uses secrets at runtime  |
'-------------------------------------------------------'
</code></pre>
<p>Both environments run the exact same code. <code>DefaultAzureCredential</code> figures out where it is — locally it picks up your <code>az login</code> session, on Azure it uses Managed Identity. You don't switch config files and you don't manage credentials. It just works.</p>
<h2 id="heading-what-is-azure-key-vault">What Is Azure Key Vault?</h2>
<p>Azure Key Vault is Microsoft's managed secret store — it handles secrets, keys, and certificates. For this tutorial, we're only using the secrets part: database passwords, API keys, JWT signing keys, anything your app needs to run but has no business being in your Git history.</p>
<p>Compared to <code>.env</code> files, the practical differences are worth understanding before you write any code.</p>
<p>Rotation is the one I notice most on real projects. Update a secret in Key Vault and every app picks it up on the next restart — no hunting down five different environment configs across staging and production.</p>
<p>Access control is the other big one. Each application only gets permission to read the secrets it actually needs. If one service gets compromised, it can't read credentials belonging to other services.</p>
<p>And every read gets logged. When something goes wrong — and eventually something will — you can see exactly which app accessed which secret, and when. That log is what auditors actually want to see.</p>
<p>I've sat in enough security reviews to know that "we use <code>.env</code> files and tell people not to commit them" doesn't satisfy an auditor. SOC 2, HIPAA, GDPR — they all want demonstrable controls. A vault with an access log is demonstrable.</p>
<h2 id="heading-set-up-the-key-vault">Set Up the Key Vault</h2>
<p>Run these commands. The vault name has to be globally unique across all of Azure — not just your own subscription — so pick something specific. Letters, numbers, and hyphens, 3 to 24 characters.</p>
<pre><code class="language-bash"># Create a resource group (skip if you already have one)
az group create \
  --name keyvault-demo-rg \
  --location eastus

# Create the Key Vault (RBAC enabled by default — required for the role assignment later)
az keyvault create \
  --name your-vault-name \
  --resource-group keyvault-demo-rg \
  --location eastus

# Grant yourself permission to manage secrets (required with RBAC — creators are not auto-assigned)
az role assignment create \
  --role "Key Vault Secrets Officer" \
  --assignee-object-id $(az ad signed-in-user show --query id -o tsv) \
  --scope $(az keyvault show \
    --name your-vault-name \
    --resource-group keyvault-demo-rg \
    --query id -o tsv)

# Add your secrets
az keyvault secret set \
  --vault-name your-vault-name \
  --name "DB-HOST" \
  --value "your-db-host.postgres.database.azure.com"

az keyvault secret set \
  --vault-name your-vault-name \
  --name "DB-PASSWORD" \
  --value "your-super-secret-password"

az keyvault secret set \
  --vault-name your-vault-name \
  --name "JWT-SECRET" \
  --value "your-jwt-signing-secret"
</code></pre>
<p>Verify the secrets were stored:</p>
<pre><code class="language-bash">az keyvault secret list --vault-name your-vault-name --query "[].name" -o tsv
</code></pre>
<p>You should see:</p>
<pre><code class="language-plaintext">DB-HOST
DB-PASSWORD
JWT-SECRET
</code></pre>
<h2 id="heading-create-the-nodejs-project">Create the Node.js Project</h2>
<p>Set up the project structure:</p>
<pre><code class="language-bash">mkdir nodejs-azure-keyvault
cd nodejs-azure-keyvault
npm init -y
npm install express pg jsonwebtoken @azure/keyvault-secrets @azure/identity dotenv
</code></pre>
<p>The two Azure packages do all the work:</p>
<ul>
<li><p><code>@azure/keyvault-secrets</code> — connects to your vault and pulls secrets out</p>
</li>
<li><p><code>@azure/identity</code> — handles auth. Locally, it uses your <code>az login</code> session, in production, it switches to Managed Identity automatically</p>
</li>
</ul>
<p>Add a start script to <code>package.json</code>:</p>
<pre><code class="language-bash">npm pkg set scripts.start="node server.js"
</code></pre>
<p>Create the following file structure:</p>
<pre><code class="language-plaintext">nodejs-azure-keyvault/
|-- src/
|   |-- config/
|   |   `-- secrets.js   # Key Vault client and secret loader
|   |-- db/
|   |   `-- index.js     # PostgreSQL pool using secrets
|   `-- routes/
|       `-- users.js     # Example route
|-- app.js               # Express app
`-- server.js            # Entry point -- loads secrets first
</code></pre>
<h2 id="heading-connect-to-key-vault-with-managed-identity">Connect to Key Vault with Managed Identity</h2>
<p>Create the secrets config file:</p>
<pre><code class="language-javascript">// src/config/secrets.js
const { SecretClient } = require('@azure/keyvault-secrets');
const { DefaultAzureCredential } = require('@azure/identity');

const VAULT_URL = `https://${process.env.KEY_VAULT_NAME}.vault.azure.net`;

const credential = new DefaultAzureCredential();
const client = new SecretClient(VAULT_URL, credential);

async function getSecret(name) {
  const secret = await client.getSecret(name);
  return secret.value;
}

module.exports = { getSecret };
</code></pre>
<p><code>DefaultAzureCredential</code> is the most important part of this setup. It tries a chain of authentication methods in order:</p>
<ol>
<li><p>Environment variables (for CI/CD pipelines)</p>
</li>
<li><p>Azure CLI credentials (for local development — <code>az login</code>)</p>
</li>
<li><p>Managed Identity (for deployed apps on Azure)</p>
</li>
</ol>
<p>This means the exact same code works locally and in production with zero changes. Locally, it uses your <code>az login</code> session. In production, it uses the app's Managed Identity. You never touch credentials.</p>
<h2 id="heading-cache-secrets-at-startup">Cache Secrets at Startup</h2>
<p>Calling Key Vault on every request adds latency and costs money. Load all secrets once at startup and cache them in memory. Replace <code>src/config/secrets.js</code> with this complete version:</p>
<pre><code class="language-javascript">// src/config/secrets.js
const { SecretClient } = require('@azure/keyvault-secrets');
const { DefaultAzureCredential } = require('@azure/identity');

const VAULT_URL = `https://${process.env.KEY_VAULT_NAME}.vault.azure.net`;

const credential = new DefaultAzureCredential();
const client = new SecretClient(VAULT_URL, credential);

// In-memory cache
const cache = {};

async function getSecret(name) {
  if (cache[name]) return cache[name];
  const secret = await client.getSecret(name);
  cache[name] = secret.value;
  return secret.value;
}

async function loadAllSecrets() {
  console.log('Loading secrets from Azure Key Vault...');
  const secretNames = ['DB-HOST', 'DB-PASSWORD', 'JWT-SECRET'];

  await Promise.all(
    secretNames.map(async (name) =&gt; {
      cache[name] = await getSecret(name);
      console.log(`  ✓ ${name} loaded`);
    })
  );

  console.log('All secrets loaded successfully.');
}

function getFromCache(name) {
  if (!cache[name]) throw new Error(`Secret "${name}" not loaded. Did loadAllSecrets() run?`);
  return cache[name];
}

module.exports = { loadAllSecrets, getFromCache };
</code></pre>
<p>The <code>loadAllSecrets</code> function runs once when the application starts. After that, all secrets are served from the in-memory cache with zero latency and zero Key Vault calls.</p>
<h2 id="heading-use-secrets-in-your-express-api">Use Secrets in Your Express API</h2>
<p>Set up the database connection using the cached secrets:</p>
<pre><code class="language-javascript">// src/db/index.js
const { Pool } = require('pg');
const { getFromCache } = require('../config/secrets');

let pool;

function getPool() {
  if (!pool) {
    pool = new Pool({
      host:     getFromCache('DB-HOST'),
      database: process.env.DB_NAME || 'myapp',
      user:     process.env.DB_USER || 'dbadmin',
      password: getFromCache('DB-PASSWORD'),
      port:     parseInt(process.env.DB_PORT || '5432'),
      ssl:      process.env.NODE_ENV === 'production'
                  ? { rejectUnauthorized: false }
                  : false,
    });

    pool.on('error', (err) =&gt; {
      console.error('Unexpected database pool error:', err.message);
    });
  }

  return pool;
}

module.exports = { getPool };
</code></pre>
<p>Notice the distinction: <code>DB-HOST</code> and <code>DB-PASSWORD</code> come from Key Vault because they're sensitive. The database name, username, and port are not — they don't need to be protected, so they use environment variables with sensible defaults. Key Vault is for credentials, not all configuration.</p>
<p>The SSL flag is environment-aware: forced on in production, off locally so Docker connections work without a certificate. The <code>rejectUnauthorized: false</code> setting accepts Azure Database for PostgreSQL's certificate without verifying the CA chain — this is standard for Azure-managed databases. For stricter environments, you can download the Azure root CA and pass it via the <code>ca</code> option in the pool config instead.</p>
<p>Create a sample route that uses JWT verification with the secret from Key Vault:</p>
<pre><code class="language-javascript">// src/routes/users.js
const express = require('express');
const jwt     = require('jsonwebtoken');
const { getFromCache } = require('../config/secrets');
const { getPool }      = require('../db');

const router = express.Router();

// Auth middleware — JWT secret comes from Key Vault, not process.env
function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or malformed Authorization header' });
  }

  const token = authHeader.split(' ')[1];

  try {
    req.user = jwt.verify(token, getFromCache('JWT-SECRET'));
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

// GET /api/users — list users (authenticated)
router.get('/', authMiddleware, async (req, res) =&gt; {
  try {
    const result = await getPool().query(
      'SELECT id, email, created_at FROM users ORDER BY created_at DESC LIMIT 20'
    );
    res.json(result.rows);
  } catch (err) {
    console.error('Database error:', err.message);
    res.status(500).json({ error: 'Internal server error' });
  }
});

// GET /api/users/:id — single user (authenticated)
router.get('/:id', authMiddleware, async (req, res) =&gt; {
  try {
    const result = await getPool().query(
      'SELECT id, email, created_at FROM users WHERE id = $1',
      [req.params.id]
    );
    if (!result.rows[0]) return res.status(404).json({ error: 'User not found' });
    res.json(result.rows[0]);
  } catch (err) {
    console.error('Database error:', err.message);
    res.status(500).json({ error: 'Internal server error' });
  }
});

module.exports = router;
</code></pre>
<p>Notice the error handler returns <code>'Internal server error'</code> instead of <code>err.message</code>. Database errors are surprisingly chatty — they'll hand an attacker your table names, column names, and query structure if you let them through.</p>
<p>Set up the Express application. Both files define <code>authMiddleware</code> locally — yes, it's duplicated. In production, I'd pull this into a shared middleware file. For this tutorial, keeping it local means you can read either file without bouncing between three others:</p>
<pre><code class="language-javascript">// app.js
const express = require('express');
const jwt = require('jsonwebtoken');
const { getFromCache } = require('./src/config/secrets');
const usersRouter = require('./src/routes/users');

const app = express();
app.use(express.json());

// Auth middleware — JWT secret comes from Key Vault, not process.env
function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or malformed Authorization header' });
  }
  const token = authHeader.split(' ')[1];
  try {
    req.user = jwt.verify(token, getFromCache('JWT-SECRET'));
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

// Health check — no auth required
app.get('/health', (req, res) =&gt; {
  res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});

// Status endpoint — proves Key Vault integration without needing a database
app.get('/api/status', authMiddleware, (req, res) =&gt; {
  res.json({
    message: 'All secrets loaded from Azure Key Vault',
    vault: process.env.KEY_VAULT_NAME,
    secrets_loaded: ['DB-HOST', 'DB-PASSWORD', 'JWT-SECRET'],
    authenticated_as: req.user.email,
    timestamp: new Date().toISOString()
  });
});

app.use('/api/users', usersRouter);

app.use((req, res) =&gt; res.status(404).json({ error: 'Route not found' }));
app.use((err, req, res, next) =&gt; {
  console.error('Unhandled error:', err.message);
  res.status(500).json({ error: 'Internal server error' });
});

module.exports = app;
</code></pre>
<p>The entry point loads secrets before starting the server. The server doesn't start unless all secrets load successfully:</p>
<pre><code class="language-javascript">// server.js
require('dotenv').config();
const app = require('./app');
const { loadAllSecrets } = require('./src/config/secrets');

const PORT = process.env.PORT || 3000;

async function start() {
  try {
    await loadAllSecrets();
    app.listen(PORT, () =&gt; {
      console.log(`Server running on port ${PORT}`);
    });
  } catch (err) {
    console.error('Failed to start server:', err.message);
    console.error('Hint: Run "az login" for local development, or check Managed Identity for Azure deployments.');
    process.exit(1);
  }
}

start();
</code></pre>
<p>That <code>process.exit(1)</code> is deliberate. I'd rather the app crash loudly at startup than limp along with missing credentials and fail on the first real request two hours later.</p>
<h2 id="heading-test-locally">Test Locally</h2>
<p>Create a <code>.env</code> file for local development. This only contains the Key Vault name, nothing sensitive:</p>
<pre><code class="language-bash"># .env
KEY_VAULT_NAME=your-vault-name
PORT=3000
</code></pre>
<p>Add <code>.env</code> and the deployment zip to <code>.gitignore</code>:</p>
<pre><code class="language-bash">echo ".env" &gt;&gt; .gitignore
echo "app.zip" &gt;&gt; .gitignore
</code></pre>
<p>Make sure you're logged into Azure CLI:</p>
<pre><code class="language-bash">az login
</code></pre>
<p>Start the application:</p>
<pre><code class="language-bash">npm start
</code></pre>
<p>You should see:</p>
<pre><code class="language-plaintext">Loading secrets from Azure Key Vault...
  ✓ JWT-SECRET loaded
  ✓ DB-PASSWORD loaded
  ✓ DB-HOST loaded
All secrets loaded successfully.
Server running on port 3000
</code></pre>
<p>The order secrets load may vary — <code>Promise.all</code> fetches them in parallel and resolves as each one completes. What matters is that all three are confirmed before the server starts.</p>
<p>Test the health endpoint:</p>
<pre><code class="language-bash">curl http://localhost:3000/health
# {"status":"healthy","timestamp":"2026-07-14T19:38:11.659Z"}
</code></pre>
<p>Now prove the integration end-to-end. Grab the value you stored as <code>JWT-SECRET</code> and use it to sign a test token — paste it in for <code>YOUR-JWT-SECRET-VALUE</code>. Then hit <code>/api/status</code> with it:</p>
<pre><code class="language-bash">node -e "const jwt = require('jsonwebtoken'); console.log(jwt.sign({id:1, email:'test@test.com'}, 'YOUR-JWT-SECRET-VALUE', {expiresIn:'1h'}));"
</code></pre>
<p>On Linux/macOS:</p>
<pre><code class="language-bash">curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3000/api/status
</code></pre>
<p>On Windows PowerShell:</p>
<pre><code class="language-powershell">Invoke-RestMethod -Uri "http://localhost:3000/api/status" -Headers @{Authorization = "Bearer YOUR_TOKEN"}
</code></pre>
<p>You should see:</p>
<pre><code class="language-json">{
  "message": "All secrets loaded from Azure Key Vault",
  "vault": "your-vault-name",
  "secrets_loaded": ["DB-HOST", "DB-PASSWORD", "JWT-SECRET"],
  "authenticated_as": "test@test.com",
  "timestamp": "2026-07-14T19:50:08.687Z"
}
</code></pre>
<p>If you got that response, the whole chain worked. The JWT was signed and verified using a secret that lived only in Key Vault — not in your code, not in your<code>.env</code>, not anywhere in the repo. Your <code>az login</code> session handled the auth locally. In production, Managed Identity takes over. Same code, nothing changes.</p>
<h3 id="heading-test-the-full-database-flow-with-docker">Test the Full Database Flow with Docker</h3>
<p>The app reads <code>DB-HOST</code> and <code>DB-PASSWORD</code> from Key Vault, so those secrets need to match your local Docker container. Update them now:</p>
<pre><code class="language-bash">az keyvault secret set --vault-name your-vault-name --name "DB-HOST" --value "localhost"
az keyvault secret set --vault-name your-vault-name --name "DB-PASSWORD" --value "demopassword123"
</code></pre>
<p>Docker up a Postgres container. The password has to match <code>demopassword123</code> — that's what you just put in Key Vault:</p>
<pre><code class="language-bash">docker run --name pg-demo \
  -e POSTGRES_USER=dbadmin \
  -e POSTGRES_PASSWORD=demopassword123 \
  -e POSTGRES_DB=myapp \
  -p 5432:5432 \
  -d postgres:15
</code></pre>
<p>Get the table created and throw in some test rows:</p>
<pre><code class="language-bash">docker exec -it pg-demo psql -U dbadmin -d myapp -c \
  "CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW());"

docker exec -it pg-demo psql -U dbadmin -d myapp -c \
  "INSERT INTO users (email) VALUES ('alice@example.com'), ('bob@example.com'), ('carol@example.com');"
</code></pre>
<p>Kill the server and bring it back up — secrets load at startup, so it needs a fresh run to pick up what you just changed in Key Vault:</p>
<pre><code class="language-bash">npm start
</code></pre>
<p>Call the users endpoint with a valid JWT:</p>
<pre><code class="language-bash"># Generate a token (use the same value you stored as JWT-SECRET in Key Vault)
node -e "const jwt = require('jsonwebtoken'); console.log(jwt.sign({id:1, email:'test@test.com'}, 'YOUR-JWT-SECRET-VALUE', {expiresIn:'1h'}));"
</code></pre>
<p>On Linux/macOS:</p>
<pre><code class="language-bash">curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3000/api/users
</code></pre>
<p>On Windows PowerShell:</p>
<pre><code class="language-powershell">Invoke-RestMethod -Uri "http://localhost:3000/api/users" -Headers @{Authorization = "Bearer YOUR_TOKEN"}
</code></pre>
<p>You should see:</p>
<pre><code class="language-json">[
  { "id": 1, "email": "alice@example.com", "created_at": "2026-07-14T19:59:21.064Z" },
  { "id": 2, "email": "bob@example.com",   "created_at": "2026-07-14T19:59:21.064Z" },
  { "id": 3, "email": "carol@example.com", "created_at": "2026-07-14T19:59:21.064Z" }
]
</code></pre>
<p>That query ran using a password that came straight from Key Vault. It's not in your <code>.env</code>, not hardcoded anywhere, and not in a local variable. The repo has nothing worth stealing.</p>
<p>Before you deploy, put the real production values back in Key Vault:</p>
<pre><code class="language-bash">az keyvault secret set --vault-name your-vault-name --name "DB-HOST" --value "your-db-host.postgres.database.azure.com"
az keyvault secret set --vault-name your-vault-name --name "DB-PASSWORD" --value "your-super-secret-password"
</code></pre>
<p>If you skip this, the deployed app will try to connect to <code>localhost</code> and fail immediately — <code>localhost</code> doesn't exist on App Service.</p>
<h2 id="heading-deploy-to-azure-app-service">Deploy to Azure App Service</h2>
<p><strong>Note:</strong> This section creates the App Service infrastructure. The actual code deployment (zip upload) happens at the end of the next section — the app must have Key Vault access configured before its first startup, or it will fail immediately and exit.</p>
<p>Create the App Service:</p>
<pre><code class="language-bash"># Create an App Service Plan (B1 is the cheapest paid tier)
az appservice plan create \
  --name keyvault-demo-plan \
  --resource-group keyvault-demo-rg \
  --sku B1 \
  --is-linux

# Create the Web App
az webapp create \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --plan keyvault-demo-plan \
  --runtime "NODE:18-lts"

# Set app settings — KEY_VAULT_NAME tells the app which vault to use
# NODE_ENV=production enables SSL for the database connection
az webapp config appsettings set \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --settings KEY_VAULT_NAME=your-vault-name NODE_ENV=production
</code></pre>
<h2 id="heading-grant-key-vault-access-to-the-app">Grant Key Vault Access to the App</h2>
<p>Enable Managed Identity on the app. This gives it an identity in Microsoft Entra ID that Key Vault can trust:</p>
<pre><code class="language-bash"># Enable system-assigned managed identity
az webapp identity assign \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg
</code></pre>
<p>The following commands capture the <code>principalId</code> automatically and use it to grant the role:</p>
<pre><code class="language-bash"># Get the principal ID
PRINCIPAL_ID=$(az webapp identity show \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --query principalId \
  --output tsv)

# Get the Key Vault resource ID
KV_ID=$(az keyvault show \
  --name your-vault-name \
  --resource-group keyvault-demo-rg \
  --query id \
  --output tsv)

# Grant the app the "Key Vault Secrets User" role
az role assignment create \
  --role "Key Vault Secrets User" \
  --assignee-object-id $PRINCIPAL_ID \
  --scope $KV_ID
</code></pre>
<p>The <code>Key Vault Secrets User</code> role allows the app to read secrets. It can't create, update, or delete them. This is the principle of least privilege — the application can only do what it needs to do.</p>
<p>Time to ship it. Linux/macOS can run this directly — Windows users, open Git Bash (it ships with Git for Windows):</p>
<pre><code class="language-bash">zip -r app.zip . -x "node_modules/*" ".git/*" ".env" "app.zip"
</code></pre>
<p>Then deploy:</p>
<pre><code class="language-bash">az webapp deployment source config-zip \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --src app.zip
</code></pre>
<p>The deployed application authenticates to Key Vault using its Managed Identity automatically. No passwords, no client secrets, no credentials of any kind in the deployment.</p>
<p>Check the health endpoint to confirm it's running:</p>
<pre><code class="language-bash">curl https://my-keyvault-node-app.azurewebsites.net/health
# {"status":"healthy","timestamp":"..."}
</code></pre>
<p>If it won't start, pull the logs:</p>
<pre><code class="language-bash">az webapp log tail --name my-keyvault-node-app --resource-group keyvault-demo-rg
</code></pre>
<p>Nine times out of ten, it's that the Key Vault role assignment has not been propagated yet. Give it 2–3 minutes, then restart:</p>
<pre><code class="language-bash">az webapp restart --name my-keyvault-node-app --resource-group keyvault-demo-rg
</code></pre>
<h2 id="heading-rotate-secrets-without-redeploying">Rotate Secrets Without Redeploying</h2>
<p>One of the biggest practical benefits of Key Vault is secret rotation. When a database password needs to change, you update it in Key Vault — not in your app:</p>
<pre><code class="language-bash">az keyvault secret set \
  --vault-name your-vault-name \
  --name "DB-PASSWORD" \
  --value "new-rotated-password"
</code></pre>
<p>The cache builds at startup, so you don't need a redeploy — a restart is enough:</p>
<pre><code class="language-bash">az webapp restart \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg
</code></pre>
<p>No code change. No new deployment. The secret is rotated, and the app is using the new value in seconds.</p>
<p>If you need zero-downtime rotation, add a <code>/refresh-secrets</code> endpoint behind admin auth that clears the cache and then calls <code>loadAllSecrets()</code>. The order matters — <code>loadAllSecrets()</code> uses <code>getSecret()</code> which returns cached values if they exist, so you must clear the cache first, or it will reload nothing. This is optional but useful for long-running processes that can't afford a restart.</p>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<p><code>CredentialUnavailableError: DefaultAzureCredential failed to retrieve a token</code></p>
<p>You're not logged into Azure CLI. Run <code>az login</code> and try again. On Azure App Service, check that Managed Identity is enabled and the role assignment was created correctly.</p>
<p><code>RestError: Forbidden — The user does not have secrets get permission</code></p>
<p>The Managed Identity isn't wired up to Key Vault yet. Go back and run the <code>az role assignment create</code> command. If you already did, it might just need time. Azure can take 2–3 minutes to propagate role assignments, so give it a moment before you dig further.</p>
<p><code>Error: Secret "DB-PASSWORD" not loaded. Did loadAllSecrets() run?</code></p>
<p><code>getFromCache()</code> ran before <code>loadAllSecrets()</code> finished, meaning the startup sequence is out of order. Open <code>server.js</code> and confirm <code>await loadAllSecrets()</code> comes before <code>app.listen()</code>. If the order's fine, the secret might just not be in the vault yet. Run <code>az keyvault secret list --vault-name YOUR_VAULT</code> to double-check. (A name mismatch — wrong case, typo — throws <code>SecretNotFound</code> instead, which is the entry below.)</p>
<p><strong>App starts locally but fails on Azure App Service</strong></p>
<p>Almost always, the app setting. Either <code>KEY_VAULT_NAME</code> isn't in App Service configuration at all, or the vault name has a typo. Run <code>az webapp log tail</code> to see the actual startup error — that'll tell you which one.</p>
<p><code>AuthorizationFailed</code> <strong>when running</strong> <code>az role assignment create</code></p>
<p>You are a guest user in your Azure tenant and lack the Owner role needed to assign roles. Switch the existing vault to the access policy model — no need to recreate it or lose your secrets:</p>
<pre><code class="language-bash">az keyvault update \
  --name your-vault-name \
  --resource-group keyvault-demo-rg \
  --enable-rbac-authorization false
</code></pre>
<p>If this happened during <strong>Set Up the Key Vault</strong> (granting yourself access), run:</p>
<pre><code class="language-bash">az keyvault set-policy \
  --name your-vault-name \
  --object-id $(az ad signed-in-user show --query id -o tsv) \
  --secret-permissions get set list delete
</code></pre>
<p>If this happened during <strong>Grant Key Vault Access to the App</strong> (granting the Managed Identity access), run:</p>
<pre><code class="language-bash">az keyvault set-policy \
  --name your-vault-name \
  --object-id $PRINCIPAL_ID \
  --secret-permissions get list
</code></pre>
<p><strong>Key Vault returns</strong> <code>SecretNotFound</code></p>
<p>The secret was never added, was deleted, or its name doesn't match exactly what your code requests — Key Vault secret names are case-sensitive. A secret named <code>db-password</code> and a request for <code>DB-PASSWORD</code> are different names. Run <code>az keyvault secret list --vault-name YOUR_VAULT</code> and compare what's actually in the vault against what <code>loadAllSecrets()</code> is asking for in <code>src/config/secrets.js</code>. Usually, it's a casing issue or a stray hyphen.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>The <code>.env</code> file in this project contains exactly one value: the Key Vault name. That's not sensitive. Every actual secret — database passwords, API keys, signing secrets — lives in Key Vault and never touches your codebase or your deployment pipeline.</p>
<p>This is the pattern I use on Azure projects now. The startup check is the part I find most useful in practice: if Key Vault is unreachable or a secret is missing, the server exits immediately with a clear error instead of starting up broken and failing on the first real request. You find out right away, rather than getting an obscure database connection error two hours later.</p>
<p>To add another secret, put it in Key Vault and drop its name into the <code>secretNames</code> array — that's it. Everything else scales with it.</p>
<p>The full working code is on GitHub: <a href="https://github.com/ziaongit/nodejs-azure-keyvault">nodejs-azure-keyvault</a></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Fix Common Web Application Security Vulnerabilities in Node.js ]]>
                </title>
                <description>
                    <![CDATA[ Here's something that tends to surprise developers who are new to security: most web vulnerabilities aren't the result of sophisticated attacks. They come from code patterns that look completely reaso ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-fix-common-web-application-security-vulnerabilities-in-node-js/</link>
                <guid isPermaLink="false">6a581c65dba34eeb664be8e2</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ owasp ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Hackita ]]>
                </dc:creator>
                <pubDate>Wed, 15 Jul 2026 23:48:53 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/b2ec81b6-d6eb-41f0-9fa5-7570914ef97d.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Here's something that tends to surprise developers who are new to security: most web vulnerabilities aren't the result of sophisticated attacks. They come from code patterns that look completely reasonable: trusting a value from the URL, applying a request body to a database update, or running two queries where one should've been enough.</p>
<p>This guide covers six of those patterns. For each one, you'll see a real code example that creates the vulnerability, an explanation of what makes it dangerous, and a corrected version with notes on exactly what changed and why.</p>
<p>No security background is required to follow along here. It'll just help to have some familiarity with Node.js and SQL.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>The examples assume you're comfortable with:</p>
<ul>
<li><p>Node.js and Express.js basics</p>
</li>
<li><p>SQL queries</p>
</li>
<li><p>How HTTP requests and responses work</p>
</li>
<li><p>Basic authentication concepts (sessions, tokens)</p>
</li>
</ul>
<p><strong>Note on code examples</strong>: Throughout this tutorial, <code>db.query()</code> is a fictional database helper that returns a single row object for <code>SELECT</code> queries (or <code>null</code> if not found), and a result object for <code>INSERT</code>/<code>UPDATE</code> queries. The <code>connection.query()</code> in the race conditions section uses the <a href="https://github.com/sidorares/node-mysql2">mysql2</a> promise API directly, where <code>query()</code> returns <code>[rows, fields]</code>. Adapt the syntax to the database driver you use.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-1-broken-access-control-and-idor">1. Broken Access Control and IDOR</a></p>
</li>
<li><p><a href="#heading-2-mass-assignment">2. Mass Assignment</a></p>
</li>
<li><p><a href="#heading-3-prototype-pollution">3. Prototype Pollution</a></p>
</li>
<li><p><a href="#heading-4-race-conditions">4. Race Conditions</a></p>
</li>
<li><p><a href="#heading-5-business-logic-flaws">5. Business Logic Flaws</a></p>
</li>
<li><p><a href="#heading-6-jwt-misconfiguration">6. JWT Misconfiguration</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-1-broken-access-control-and-idor">1. Broken Access Control and IDOR</h2>
<p><a href="https://owasp.org/Top10/A01_2021-Broken_Access_Control/">Broken Access Control</a> has topped the OWASP Top 10 since 2021, and it's not hard to see why. The most common form is <strong>Insecure Direct Object Reference (IDOR)</strong>: the application exposes a database ID in a URL, a user changes the number, and suddenly they're looking at someone else's data.</p>
<p>The fix seems obvious in hindsight. But it keeps appearing in production code because authentication and authorization get conflated. Confirming that a user is logged in is not the same as confirming they're allowed to access a specific resource.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>Here's a typical user profile endpoint:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.get('/api/users/:id/profile', authenticate, async (req, res) =&gt; {
  const userId = req.params.id;

  const user = await db.query(
    'SELECT id, name, email, address FROM users WHERE id = ?',
    [userId]
  );

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  res.json(user);
});
</code></pre>
<p>The <code>authenticate</code> middleware confirms that the request includes a valid token. But it doesn't confirm whether the authenticated user is allowed to access the requested resource.</p>
<p>Any authenticated user can request <code>/api/users/1/profile</code>, <code>/api/users/2/profile</code>, and so on and retrieve other users' data.</p>
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p>Authentication confirms <em>who</em> you are. Authorization confirms <em>what you're allowed to do</em>. The code above does the first and skips the second entirely.</p>
<p>With a sequential numeric ID, a curious user doesn't need any special tools. They can just change <code>1</code> to <code>2</code> in the URL. But the same problem exists with UUIDs or slugs if the ownership check is missing.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Verify server-side that the authenticated user owns — or is explicitly authorized to access — the requested resource:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.get('/api/users/:id/profile', authenticate, async (req, res) =&gt; {
  // Reject anything that isn't a string of digits — parseInt("12abc") would return 12
  if (!/^\d+$/.test(req.params.id)) {
    return res.status(400).json({ error: 'Invalid user ID' });
  }

  const requestedId = Number(req.params.id);

  if (requestedId &lt; 1) {
    return res.status(400).json({ error: 'Invalid user ID' });
  }

  // The authenticated user's ID is set by the authenticate middleware
  const authenticatedId = req.user.id;

  // Enforce ownership: users can only access their own profile
  if (requestedId !== authenticatedId) {
    return res.status(403).json({ error: 'Forbidden' });
  }

  const user = await db.query(
    'SELECT id, name, email, address FROM users WHERE id = ?',
    [requestedId]
  );

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  res.json(user);
});
</code></pre>
<p>For admin endpoints that legitimately need to access any user, enforce role-based authorization explicitly:</p>
<pre><code class="language-javascript">// Admin endpoint with explicit role check
app.get('/api/admin/users/:id', authenticate, requireRole('admin'), async (req, res) =&gt; {
  if (!/^\d+$/.test(req.params.id)) {
    return res.status(400).json({ error: 'Invalid user ID' });
  }

  const userId = Number(req.params.id);

  const user = await db.query(
    'SELECT id, name, email, role FROM users WHERE id = ?',
    [userId]
  );

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  res.json(user);
});
</code></pre>
<p>Here's what changed and why:</p>
<ul>
<li><p><code>/^\d+$/.test(req.params.id)</code> rejects anything that isn't a pure string of digits. <code>parseInt("12abc", 10)</code> would silently return <code>12</code> and pass further checks. The regex prevents this.</p>
</li>
<li><p><code>Number(req.params.id)</code> converts the already-validated string to a number safely.</p>
</li>
<li><p>The comparison <code>requestedId !== authenticatedId</code> enforces ownership.</p>
</li>
<li><p>Admin functionality is a separate endpoint with its own authorization check.</p>
</li>
</ul>
<p><strong>Never infer authorization from a URL parameter. Derive it from the authenticated session.</strong></p>
<h2 id="heading-2-mass-assignment">2. Mass Assignment</h2>
<p>Mass assignment is one of those vulnerabilities that's almost invisible when you write it. You're just being efficient, right? Why iterate over fields manually when you can pass the whole object?</p>
<p>The problem is that your database table knows about fields your users were never supposed to touch.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>Here's a user profile update endpoint:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.put('/api/users/me', authenticate, async (req, res) =&gt; {
  const userId = req.user.id;

  // req.body contains everything the client sends
  const updates = req.body;

  await db.query(
    'UPDATE users SET ? WHERE id = ?',
    [updates, userId]
  );

  res.json({ success: true });
});
</code></pre>
<p>The <code>users</code> table has these columns:</p>
<pre><code class="language-sql">CREATE TABLE users (
  id           INT PRIMARY KEY,
  name         VARCHAR(100),
  email        VARCHAR(100),
  bio          TEXT,
  role         ENUM('user', 'moderator', 'admin') DEFAULT 'user',
  credits      INT DEFAULT 0,
  is_banned    BOOLEAN DEFAULT false
);
</code></pre>
<p>The developer intended users to update <code>name</code>, <code>email</code>, and <code>bio</code>. But <code>role</code>, <code>credits</code>, and <code>is_banned</code> are also in the table — and the query updates whatever fields the client sends.</p>
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p>That request body goes straight into the SQL query. The <code>users</code> table also has <code>role</code>, <code>credits</code>, and <code>is_banned</code> — and the query doesn't know or care which fields the developer "intended" to expose.</p>
<p>A user who sends this:</p>
<pre><code class="language-json">{
  "name": "Alice",
  "role": "admin",
  "credits": 100000,
  "is_banned": false
}
</code></pre>
<p>Has just promoted themselves to admin, cleared their ban, and given themselves a hundred thousand credits.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Build the update object yourself, field by field, using an explicit allowlist:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.put('/api/users/me', authenticate, async (req, res) =&gt; {
  const userId = req.user.id;

  // Only these fields may be updated by the user
  const ALLOWED_FIELDS = ['name', 'email', 'bio'];
  const updates = {};

  for (const field of ALLOWED_FIELDS) {
    if (req.body[field] !== undefined) {
      updates[field] = req.body[field];
    }
  }

  if (Object.keys(updates).length === 0) {
    return res.status(400).json({ error: 'No valid fields provided' });
  }

  // Validate individual fields
  // isValidEmail is a simple helper: /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
  if (updates.email &amp;&amp; !isValidEmail(updates.email)) {
    return res.status(400).json({ error: 'Invalid email format' });
  }

  if (updates.name &amp;&amp; (typeof updates.name !== 'string' || updates.name.length &gt; 100)) {
    return res.status(400).json({ error: 'Name must be a string of 100 characters or fewer' });
  }

  await db.query(
    'UPDATE users SET ? WHERE id = ?',
    [updates, userId]
  );

  res.json({ success: true });
});
</code></pre>
<p>For admin operations that legitimately update sensitive fields, use a separate endpoint with its own authorization:</p>
<pre><code class="language-javascript">// Admin-only endpoint for changing user roles
app.put('/api/admin/users/:id/role', authenticate, requireRole('admin'), async (req, res) =&gt; {
  if (!/^\d+$/.test(req.params.id)) {
    return res.status(400).json({ error: 'Invalid user ID' });
  }

  const userId = Number(req.params.id);
  const { role } = req.body;

  const VALID_ROLES = ['user', 'moderator', 'admin'];

  if (!VALID_ROLES.includes(role)) {
    return res.status(400).json({ error: 'Invalid role' });
  }

  await db.query(
    'UPDATE users SET role = ? WHERE id = ?',
    [role, userId]
  );

  res.json({ success: true });
});
</code></pre>
<p><strong>Don't spread</strong> <code>req.body</code> <strong>into a database query. Build the update object field by field.</strong></p>
<h2 id="heading-3-prototype-pollution">3. Prototype Pollution</h2>
<p>Prototype pollution occurs when untrusted data is merged into an object recursively, allowing an attacker to inject properties into <code>Object.prototype</code> (the base object that every plain JavaScript object inherits from).</p>
<p>The OWASP Top 10 covers this under <a href="https://owasp.org/Top10/A08_2021-Software_and_Data_Integrity_Failures/">Software and Data Integrity Failures (A08:2021)</a>.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>A configuration merge utility that processes user-supplied settings:</p>
<pre><code class="language-javascript">// Vulnerable recursive merge function
function mergeConfig(target, source) {
  for (const key of Object.keys(source)) {
    if (typeof source[key] === 'object' &amp;&amp; source[key] !== null) {
      if (!target[key]) target[key] = {};
      mergeConfig(target[key], source[key]); // recursive call
    } else {
      target[key] = source[key]; // triggers __proto__ setter via bracket notation
    }
  }
}

app.post('/api/settings', authenticate, (req, res) =&gt; {
  const userSettings = {};
  mergeConfig(userSettings, req.body); // merge untrusted input
  applySettings(userSettings);
  res.json({ success: true });
});
</code></pre>
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p>An attacker sends this request body:</p>
<pre><code class="language-json">{
  "__proto__": {
    "isAdmin": true
  }
}
</code></pre>
<p>The recursive <code>mergeConfig</code> function reaches the <code>__proto__</code> key and executes <code>target['__proto__']['isAdmin'] = true</code>. Because <code>__proto__</code> is JavaScript's prototype accessor, this writes directly to <code>Object.prototype</code>. After the merge:</p>
<pre><code class="language-javascript">const anyObject = {};
console.log(anyObject.isAdmin); // true — inherited from Object.prototype
</code></pre>
<p>Every plain object in the running application now inherits <code>isAdmin: true</code>. If any authorization check looks like this:</p>
<pre><code class="language-javascript">if (user.isAdmin) { /* grant admin access */ }
</code></pre>
<p>That check now passes for every user, regardless of their actual role.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Store user state server-side and validate each field individually. Never recursively merge untrusted input:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.post('/api/settings', authenticate, async (req, res) =&gt; {
  // Load current settings from the database — never from the client
  const current = await db.query(
    'SELECT theme, language, notifications FROM user_settings WHERE user_id = ?',
    [req.user.id]
  );

  // Validate each field against an explicit allowlist
  const safeSettings = {
    theme: validateEnum(req.body.theme, ['light', 'dark'], current.theme),
    language: validateEnum(req.body.language, ['en', 'es', 'fr', 'de'], current.language),
    notifications: typeof req.body.notifications === 'boolean'
      ? req.body.notifications
      : current.notifications
  };

  await db.query(
    'UPDATE user_settings SET ? WHERE user_id = ?',
    [safeSettings, req.user.id]
  );

  res.json({ success: true });
});

function validateEnum(value, allowed, defaultValue) {
  return allowed.includes(value) ? value : defaultValue;
}
</code></pre>
<p>If you need a merge utility, use <code>Object.create(null)</code> as the base — it has no prototype, so <code>__proto__</code> can't be polluted — and allowlist keys explicitly:</p>
<pre><code class="language-javascript">// Safe merge: base object with no prototype
function safeMerge(allowedKeys, source) {
  const result = Object.create(null); // no prototype = no pollution possible

  for (const key of allowedKeys) {
    if (key in source &amp;&amp; typeof source[key] !== 'object') {
      result[key] = source[key];
    }
  }

  return result;
}
</code></pre>
<p><strong>Rules</strong>:</p>
<ul>
<li><p>Never recursively merge untrusted input into a plain object.</p>
</li>
<li><p>Store application state server-side. Don't trust clients to carry it.</p>
</li>
<li><p>Use <code>Object.create(null)</code> for data containers that will hold untrusted keys.</p>
</li>
<li><p>Validate each field by type and allowed values before using it.</p>
</li>
</ul>
<h2 id="heading-4-race-conditions">4. Race Conditions</h2>
<p>Race conditions are tricky because the code is perfectly correct. It's the timing that breaks it. Two requests arrive at almost the same moment, both check the same condition, both see a valid result, and both proceed. The result is something that should only happen once, happening twice.</p>
<p>This is called a <strong>Time-of-Check to Time-of-Use (TOCTOU)</strong> problem: the state you checked is no longer the state you're acting on.</p>
<h3 id="heading-how-to-identify-this-pattern-in-your-code">How to Identify This Pattern in Your Code</h3>
<p>A single-use coupon redemption endpoint:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.post('/api/redeem-coupon', authenticate, async (req, res) =&gt; {
  const { couponCode } = req.body;
  const userId = req.user.id;

  // Step 1: Check if the coupon is still valid
  const coupon = await db.query(
    'SELECT id, discount_amount, used FROM coupons WHERE code = ? AND used = false',
    [couponCode]
  );

  if (!coupon) {
    return res.status(400).json({ error: 'Invalid or already used coupon' });
  }

  // Time gap: another request can pass Step 1 here before Step 3 runs

  // Step 2: Apply the discount
  await applyDiscountToOrder(userId, coupon.discount_amount);

  // Step 3: Mark coupon as used
  await db.query(
    'UPDATE coupons SET used = true, used_by = ? WHERE code = ?',
    [userId, couponCode]
  );

  res.json({ success: true });
});
</code></pre>
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p>Two requests arrive with the same coupon code at nearly the same time. Both hit Step 1 before either reaches Step 3. Both read <code>used = false</code>. Both apply the discount. One coupon, used twice.</p>
<p>The same window exists anywhere you read-then-write: balance checks before deductions, inventory checks before reservations, vote checks before incrementing. Any of them can be exploited the same way.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Replace the check-then-act pattern with an atomic operation. An atomic database update guarantees that the condition check and the write happen as a single indivisible unit:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.post('/api/redeem-coupon', authenticate, async (req, res) =&gt; {
  const { couponCode } = req.body;
  const userId = req.user.id;

  const connection = await db.getConnection();

  try {
    await connection.beginTransaction();

    // Atomic: only one request can update a row where used = false.
    // The database row lock ensures only one request succeeds.
    const [result] = await connection.query(
      `UPDATE coupons
       SET used = true, used_by = ?, used_at = NOW()
       WHERE code = ? AND used = false`,
      [userId, couponCode]
    );

    if (result.affectedRows === 0) {
      await connection.rollback();
      return res.status(400).json({ error: 'Invalid or already used coupon' });
    }

    const [couponRows] = await connection.query(
      'SELECT discount_amount FROM coupons WHERE code = ?',
      [couponCode]
    );
    const coupon = couponRows[0];

    await applyDiscountToOrder(userId, coupon.discount_amount, connection);

    await connection.commit();

    res.json({ success: true, discount: coupon.discount_amount });

  } catch (error) {
    await connection.rollback();
    console.error('Coupon redemption failed:', error);
    res.status(500).json({ error: 'Could not process the coupon' });
  } finally {
    connection.release();
  }
});
</code></pre>
<p>The key change is <code>UPDATE ... WHERE code = ? AND used = false</code>. The database acquires a row lock during the update. Only one concurrent request can succeed. The second request finds <code>affectedRows = 0</code> and returns an error — correctly.</p>
<p><strong>Any time you read a value to make a decision before writing — that's a potential race condition. Make the check and the write atomic.</strong></p>
<h2 id="heading-5-business-logic-flaws">5. Business Logic Flaws</h2>
<p>Business logic flaws are the hardest category to catch. Automated scanners won't find them, and code review might miss them too, because the code works exactly as written. The problem is in what the code was designed to do, not how it does it.</p>
<p>The most common form: trusting the client to send sensible numeric values.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>An e-commerce checkout endpoint:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.post('/api/checkout', authenticate, async (req, res) =&gt; {
  const { items } = req.body;

  let total = 0;
  const processedItems = [];

  for (const item of items) {
    const product = await db.query(
      'SELECT id, price FROM products WHERE id = ?',
      [item.productId]
    );

    if (!product) {
      return res.status(400).json({ error: `Product not found: ${item.productId}` });
    }

    // Trust the quantity value from the client
    const itemTotal = product.price * item.quantity;
    total += itemTotal;

    processedItems.push({ productId: product.id, quantity: item.quantity, price: product.price });
  }

  if (total &gt; 100) {
    total = total * 0.9; // 10% discount
  }

  await createOrder(req.user.id, processedItems, total);
  res.json({ success: true, total });
});
</code></pre>
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p><strong>Problem 1 — Negative quantity</strong>: The code multiplies the server's price by the client's quantity without checking that the quantity is positive. A user sends <code>"quantity": -5</code> on an expensive item. Its contribution to the total becomes negative, reducing the overall total.</p>
<p><strong>Problem 2 — Floating point arithmetic</strong>: <code>0.1 + 0.2</code> in JavaScript is <code>0.30000000000000004</code>. Without rounding, financial calculations accumulate errors over time.</p>
<p><strong>Problem 3 — Discount manipulation</strong>: If a separate endpoint allows modifying an order after checkout without recalculating the total, a user could add items to earn the discount, then remove items while keeping the discounted price.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Validate every numeric input and recalculate all totals server-side. Use integer arithmetic (cents) for money to avoid floating point errors:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.post('/api/checkout', authenticate, async (req, res) =&gt; {
  const { items } = req.body;

  if (!Array.isArray(items) || items.length === 0) {
    return res.status(400).json({ error: 'Cart must contain at least one item' });
  }

  if (items.length &gt; 50) {
    return res.status(400).json({ error: 'Cart cannot contain more than 50 items' });
  }

  let totalCents = 0; // Integer arithmetic avoids floating point errors
  const processedItems = [];

  for (const item of items) {
    if (!/^\d+$/.test(String(item.productId))) {
      return res.status(400).json({ error: `Invalid product ID: ${item.productId}` });
    }

    const productId = Number(item.productId);

    // Validate quantity: must be a string of digits only, between 1 and 10
    // parseInt("3abc", 10) returns 3 — we use regex to prevent this
    if (!/^\d+$/.test(String(item.quantity))) {
      return res.status(400).json({
        error: `Invalid quantity for product ${productId}`
      });
    }

    const quantity = Number(item.quantity);

    if (quantity &lt; 1 || quantity &gt; 10) {
      return res.status(400).json({
        error: `Quantity for product ${productId} must be between 1 and 10`
      });
    }

    const product = await db.query(
      'SELECT id, name, price_cents, stock FROM products WHERE id = ? AND active = true',
      [productId]
    );

    if (!product) {
      return res.status(400).json({ error: `Product not found: ${productId}` });
    }

    if (product.stock &lt; quantity) {
      return res.status(400).json({
        error: `Insufficient stock for "${product.name}"`
      });
    }

    // Use the server's price — never trust a price from the client
    totalCents += product.price_cents * quantity;

    processedItems.push({
      productId: product.id,
      name: product.name,
      quantity,
      unitPriceCents: product.price_cents
    });
  }

  // Integer arithmetic for the discount calculation
  const discountMultiplier = totalCents &gt; 10000 ? 90 : 100; // 10000 cents = $100
  const finalTotalCents = Math.round(totalCents * discountMultiplier / 100);

  await createOrder(req.user.id, processedItems, finalTotalCents);

  res.json({
    success: true,
    total: (finalTotalCents / 100).toFixed(2),
    currency: 'USD'
  });
});
</code></pre>
<p>What changed and why:</p>
<ul>
<li><p>Integer (cents) arithmetic eliminates floating point errors. <code>10000 + 3000</code> in integer cents is always exact.</p>
</li>
<li><p><code>quantity &lt; 1 || quantity &gt; 10</code> prevents negative quantities and unreasonably large orders.</p>
</li>
<li><p><code>items.length &gt; 50</code> prevents oversized requests.</p>
</li>
<li><p><code>product.stock &lt; quantity</code> ensures the order doesn't exceed available inventory.</p>
</li>
<li><p>All totals, discounts, and final prices are calculated server-side from server-side prices.</p>
</li>
</ul>
<p><strong>Define the valid range for every numeric input. Recalculate every total server-side. The client isn't a trusted source for prices or quantities.</strong></p>
<h2 id="heading-6-jwt-misconfiguration">6. JWT Misconfiguration</h2>
<p>JWTs are everywhere in Node.js APIs, and the <code>jsonwebtoken</code> library makes them easy to use. That's both good and bad: they're easy to use correctly, but they're also easy to use in ways that look fine until they're not.</p>
<p>The OWASP Top 10 classifies authentication failures under <a href="https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/">Identification and Authentication Failures (A07:2021)</a>. Three misconfigurations show up repeatedly.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p><strong>Mistake 1 — Algorithm not specified in</strong> <code>verify()</code>:</p>
<pre><code class="language-javascript">// Vulnerable
const decoded = jwt.verify(token, process.env.JWT_SECRET);
</code></pre>
<p>Without an <code>algorithms</code> option, some JWT implementations can be tricked into accepting tokens that declare <code>"alg": "none"</code> in their header — meaning no signature is required at all.</p>
<p><strong>Mistake 2 — Weak or hardcoded secrets</strong>:</p>
<pre><code class="language-javascript">// Vulnerable
const token = jwt.sign({ userId: user.id }, 'secret');
</code></pre>
<p>A short or predictable HS256 secret can be brute-forced offline if an attacker obtains a valid token.</p>
<p><strong>Mistake 3 — Sensitive data in the payload</strong>:</p>
<pre><code class="language-javascript">// Vulnerable
const token = jwt.sign({
  userId: user.id,
  passwordHash: user.passwordHash, // never do this
  role: user.role
}, secret);
</code></pre>
<p>JWT payloads are base64-encoded, not encrypted. Anyone who holds the token can decode the payload and read its contents.</p>
<h3 id="heading-how-to-fix-these-issues">How to Fix These Issues</h3>
<pre><code class="language-javascript">// Secure JWT implementation
const jwt = require('jsonwebtoken');
const crypto = require('crypto');

// Generate a strong secret once and store it as an environment variable:
// node -e "console.log(crypto.randomBytes(64).toString('hex'))"
const JWT_SECRET = process.env.JWT_SECRET;

if (!JWT_SECRET || Buffer.from(JWT_SECRET, 'hex').length &lt; 32) {
  throw new Error('JWT_SECRET must be at least 32 random bytes');
}

function signToken(userId) {
  return jwt.sign(
    { sub: userId },         // 'sub' is the standard claim for the user identifier
    JWT_SECRET,
    {
      algorithm: 'HS256',    // always declare the algorithm explicitly
      expiresIn: '15m',      // short-lived tokens reduce the window if a token is stolen
      issuer: 'your-app-name',
      audience: 'your-app-name'
    }
  );
}

function verifyToken(token) {
  return jwt.verify(token, JWT_SECRET, {
    algorithms: ['HS256'],   // whitelist only the expected algorithm
    issuer: 'your-app-name',
    audience: 'your-app-name'
  });
}

function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = verifyToken(token);
    req.user = { id: decoded.sub };
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}
</code></pre>
<p><strong>A note on token revocation</strong>: JWTs are stateless, meaning your server keeps no record of them. This means you can't immediately invalidate a token on logout or account compromise without extra infrastructure.</p>
<p>Common solutions include short-lived access tokens (15 minutes) paired with refresh tokens stored server-side, or a token denylist in a fast store like Redis. Choose the approach that matches your application's requirements.</p>
<p><strong>JWT security checklist</strong>:</p>
<ul>
<li><p>Always pass <code>algorithms: ['HS256']</code> to <code>verify()</code>.</p>
</li>
<li><p>Use a secret of at least 32 random bytes generated with <code>crypto.randomBytes</code>.</p>
</li>
<li><p>Set short expiration times.</p>
</li>
<li><p>Store only non-sensitive identifiers (user ID) in the payload — not emails, passwords, or roles.</p>
</li>
<li><p>Use standard claims: <code>sub</code>, <code>iss</code>, <code>aud</code>, <code>exp</code>.</p>
</li>
<li><p>Plan your revocation strategy before going to production.</p>
</li>
</ul>
<h2 id="heading-summary">Summary</h2>
<p>Here's a quick reference for the six vulnerability categories covered in this tutorial:</p>
<table>
<thead>
<tr>
<th>Vulnerability</th>
<th>Root Cause</th>
<th>Core Fix</th>
</tr>
</thead>
<tbody><tr>
<td>IDOR</td>
<td>Authorization check missing</td>
<td>Verify ownership from the authenticated session</td>
</tr>
<tr>
<td>Mass Assignment</td>
<td>All request body fields applied to database</td>
<td>Allowlist allowed fields explicitly</td>
</tr>
<tr>
<td>Prototype Pollution</td>
<td>Recursive merge of untrusted input</td>
<td>Store state server-side. Use <code>Object.create(null)</code> for merge targets.</td>
</tr>
<tr>
<td>Race Condition</td>
<td>Check-then-act without atomicity</td>
<td>Atomic <code>UPDATE ... WHERE condition</code> or transactions</td>
</tr>
<tr>
<td>Business Logic Flaw</td>
<td>Trusting client-supplied numeric values</td>
<td>Regex-validate inputs. Use integer arithmetic for money.</td>
</tr>
<tr>
<td>JWT Misconfiguration</td>
<td>No algorithm allowlist, weak secrets</td>
<td>Explicit algorithm array, strong random secret, short expiration</td>
</tr>
</tbody></table>
<p>None of these vulnerabilities requires a clever attacker. They just need code that trusts the wrong thing at the wrong time.</p>
<p>The patterns worth internalizing: always derive authorization from the session, never from the request. Validate every numeric input with a range. Make financial writes atomic. Build your JWT configuration explicitly.</p>
<p>Understanding how to fix these vulnerabilities is one side of the picture. Understanding how they're actually identified and exploited during a real security assessment is the other.</p>
<p>If you want to go deeper on the offensive side — how penetration testers approach web application targets, what they look for, and how they chain multiple flaws together — this <a href="https://hackita.it/articoli/attacchi-applicazioni-web/">guide to web application attack techniques</a> covers that perspective in detail.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a RAG Chatbot for Your Docs with Node.js, Google Gemini, and pgvector ]]>
                </title>
                <description>
                    <![CDATA[ I was helping a team that had a 200-page API documentation PDF. Every new engineer spent their first two weeks Ctrl+F-ing through it, asking the same questions in Slack, getting redirected to the same ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-rag-chatbot-nodejs-gemini-pgvector/</link>
                <guid isPermaLink="false">6a57a6aa328507d0d4d46169</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ PostgreSQL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tutorial ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ RAG  ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Wed, 15 Jul 2026 15:26:34 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/9aa3d8d3-9c51-42a7-8e78-907802394ea1.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>I was helping a team that had a 200-page API documentation PDF. Every new engineer spent their first two weeks Ctrl+F-ing through it, asking the same questions in Slack, getting redirected to the same paragraphs on page 47.</p>
<p>The doc was accurate. It was even well-written. But nobody could find anything in it fast enough for it to be useful.</p>
<p>That's the problem RAG, or Retrieval-Augmented Generation, solves.</p>
<p>The naïve approach is to stuff your entire PDF into a prompt and let the model figure it out. That breaks down fast: context windows overflow, costs spike on every request, and the model loses the thread somewhere in the wall of text.</p>
<p>RAG takes a different approach. Your documents get broken into small chunks upfront. Ask it a question and it digs out the 3 or 4 chunks that best match it — those are what the model actually sees. The model gets a tight, focused context. The answer comes from what your document actually says — not from whatever the LLM memorized during training.</p>
<p>In this tutorial, you'll build that from scratch. Upload any PDF — an API reference, an internal spec, a research paper — and ask questions about it in plain English. The system finds the relevant sections and answers from the document itself, not from general training data.</p>
<p>The stack: Node.js with Express, Google Gemini for embeddings, Groq for text generation, and pgvector running in Docker. Every piece of it is free — no credit card, no trial period.</p>
<p>The complete code is on GitHub at <a href="https://github.com/ziaongit/nodejs-rag-chatbot">nodejs-rag-chatbot</a>.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-how-rag-works">How RAG Works</a></p>
</li>
<li><p><a href="#heading-what-were-building">What We're Building</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-set-up-postgres-with-pgvector-using-docker">Set Up Postgres with pgvector Using Docker</a></p>
</li>
<li><p><a href="#heading-connect-to-the-database">Connect to the Database</a></p>
</li>
<li><p><a href="#heading-build-the-ingestion-pipeline">Build the Ingestion Pipeline</a></p>
</li>
<li><p><a href="#heading-build-the-query-pipeline">Build the Query Pipeline</a></p>
</li>
<li><p><a href="#heading-build-the-chat-api-with-express">Build the Chat API with Express</a></p>
</li>
<li><p><a href="#heading-test-the-chatbot">Test the Chatbot</a></p>
</li>
<li><p><a href="#heading-troubleshooting">Troubleshooting</a></p>
</li>
<li><p><a href="#heading-how-to-swap-in-openai">How to Swap in OpenAI</a></p>
</li>
<li><p><a href="#heading-what-to-build-next">What to Build Next</a></p>
</li>
</ul>
<h2 id="heading-how-rag-works">How RAG Works</h2>
<p>RAG has two phases, and the code maps directly to both.</p>
<p><strong>Ingestion phase</strong> — runs once when you upload a document:</p>
<ol>
<li><p>Pull the raw text out of the PDF</p>
</li>
<li><p>Break it into chunks of 400 to 600 characters each, with a bit of overlap so nothing important gets cut at a boundary</p>
</li>
<li><p>Run each chunk through an embedding model, which turns it into a vector (a long list of numbers that captures what the text means)</p>
</li>
<li><p>Store each chunk and its vector in Postgres</p>
</li>
</ol>
<p><strong>Query phase</strong> — runs every time someone asks a question:</p>
<ol>
<li><p>Embed the user's question using the same model</p>
</li>
<li><p>Search the database for chunks whose vectors are closest to the question vector</p>
</li>
<li><p>Take the top 5 matching chunks and assemble them into a context block</p>
</li>
<li><p>Send <code>context + question</code> to the LLM and return its answer</p>
</li>
</ol>
<p>The reason this works better than keyword search: the embedding model captures <em>meaning</em>, not just exact words. If your doc says "terminate the process" and the user asks "how do I stop it?", vector similarity finds that match. Regular string matching doesn't.</p>
<p>One thing that trips people up: you must use the same embedding model at query time as you did at ingestion. The model defines the geometric space those vectors live in. Switch models halfway through and the coordinates stop meaning the same thing — you'd be comparing apples to completely different apples.</p>
<h2 id="heading-what-were-building">What We're Building</h2>
<p>The architecture is intentionally minimal: two endpoints, with nothing you don't need:</p>
<ul>
<li><p><code>POST /ingest</code>: accepts a PDF upload, chunks it, embeds each chunk, stores vectors in pgvector</p>
</li>
<li><p><code>POST /chat</code>: accepts a question, retrieves the most relevant chunks, returns an LLM-generated answer</p>
</li>
</ul>
<p>The full tech stack:</p>
<ul>
<li><p><strong>Node.js + Express</strong> — API layer</p>
</li>
<li><p><strong>Google Gemini free API</strong> — <code>gemini-embedding-001</code> for embeddings (3,072 dimensions per chunk)</p>
</li>
<li><p><strong>Groq free API</strong> — <code>llama-3.1-8b-instant</code> for text generation</p>
</li>
<li><p><strong>PostgreSQL + pgvector</strong> — vector storage and cosine similarity search, running in Docker</p>
</li>
<li><p><strong>pdf-parse</strong> — extracts raw text from PDF buffers</p>
</li>
</ul>
<p>Gemini handles embeddings and Groq handles generation. Splitting them across two providers isn't arbitrary. Gemini's generation API has a quota limit of zero in certain regions (including Pakistan), while Groq works everywhere with no restrictions. Using Groq for generation means this tutorial runs the same way regardless of where you are.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start:</p>
<ul>
<li><p>Node.js 20+ installed on your machine</p>
</li>
<li><p>Docker Desktop running (this is how we'll run Postgres locally)</p>
</li>
<li><p>A free Google Gemini API key (for embeddings)</p>
</li>
<li><p>A free Groq API key (for text generation)</p>
</li>
</ul>
<h3 id="heading-how-to-get-your-free-gemini-api-key">How to Get Your Free Gemini API Key</h3>
<ol>
<li><p>Go to <a href="https://aistudio.google.com/app/apikey">aistudio.google.com/app/apikey</a> and sign in with a Google account</p>
</li>
<li><p>Click "Create API key"</p>
</li>
<li><p>Select "Create API key in new project"</p>
</li>
<li><p>Copy the key — it starts with <code>AIzaSy...</code></p>
</li>
</ol>
<p>No credit card or billing required.</p>
<h3 id="heading-how-to-get-your-free-groq-api-key">How to Get Your Free Groq API Key</h3>
<ol>
<li><p>Go to <a href="https://console.groq.com">console.groq.com</a> and sign up with Google</p>
</li>
<li><p>Click "API Keys" in the left sidebar</p>
</li>
<li><p>Click "Create API Key", give it a name, copy the key — it starts with <code>gsk_...</code></p>
</li>
</ol>
<p>Groq is free with generous rate limits and works in all regions.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>Create the project directory and initialize it:</p>
<pre><code class="language-bash">mkdir nodejs-rag-chatbot
cd nodejs-rag-chatbot
npm init -y
</code></pre>
<p>Install dependencies:</p>
<pre><code class="language-bash">npm install express pg pdf-parse uuid dotenv multer
npm install --save-dev nodemon
</code></pre>
<p>A quick note on the packages: <code>multer</code> is what makes file uploads work on the <code>/ingest</code> endpoint. Without it, Express can't parse multipart form data.</p>
<p><code>pdf-parse</code> does the heavy lifting on PDFs, though watch out for scanned PDFs. Those are just images with no text layer underneath, so you'll get back an empty string.</p>
<p><code>pg</code> talks to Postgres, <code>uuid</code> gives each row a unique ID, and <code>dotenv</code> loads your keys before the app does anything.</p>
<p>Create a <code>.env</code> in the project root. It needs seven values:</p>
<pre><code class="language-plaintext">GEMINI_API_KEY=AIzaSy...         ← your Gemini key from Google AI Studio
GROQ_API_KEY=gsk_...             ← your Groq key from console.groq.com
POSTGRES_USER=rag_user
POSTGRES_PASSWORD=rag_pass       ← choose any password, this is local only
POSTGRES_DB=rag_db
DATABASE_URL=postgresql://rag_user:rag_pass@localhost:5432/rag_db
PORT=3000
</code></pre>
<p>One thing: the password in <code>POSTGRES_PASSWORD</code> and the one in <code>DATABASE_URL</code> must match exactly. I changed just one of them once and spent way too long debugging a "password authentication failed" error before realising the two values were out of sync.</p>
<p>Update <code>package.json</code> scripts:</p>
<pre><code class="language-json">"scripts": {
  "start": "node src/index.js",
  "dev": "nodemon src/index.js"
}
</code></pre>
<p>Create the <code>src</code> directory:</p>
<pre><code class="language-bash">mkdir src
</code></pre>
<p>Your final folder structure will look like this:</p>
<pre><code class="language-plaintext">nodejs-rag-chatbot/
├── src/
│   ├── index.js        ← Express app entry point
│   ├── db.js           ← Postgres connection and schema setup
│   ├── embeddings.js   ← Gemini embedding + Groq generation
│   ├── ingest.js       ← Document ingestion pipeline
│   └── query.js        ← RAG query pipeline
├── docker-compose.yml
├── .env
└── package.json
</code></pre>
<h2 id="heading-set-up-postgres-with-pgvector-using-docker">Set Up Postgres with pgvector Using Docker</h2>
<p>pgvector adds a <code>vector</code> column type to Postgres and the operators needed to search it by similarity. Normally you'd have to install it yourself, but the <code>pgvector/pgvector</code> Docker image ships with it already baked in. Just pull the image and you're good.</p>
<p>Now add <code>docker-compose.yml</code> to the project root:</p>
<pre><code class="language-yaml">services:
  postgres:
    image: pgvector/pgvector:pg16
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
</code></pre>
<p>Those <code>${VARIABLE}</code> references get swapped out from <code>.env</code> when Compose starts — so <code>docker-compose.yml</code> itself stays clean. This is worth doing from day one. I've seen people skip this and regret it after a repo goes public.</p>
<p>Start it:</p>
<pre><code class="language-bash">docker compose up -d
</code></pre>
<h2 id="heading-connect-to-the-database">Connect to the Database</h2>
<p>Create <code>src/db.js</code>. This sets up the connection pool and creates the <code>documents</code> table on first run:</p>
<pre><code class="language-javascript">const { Pool } = require('pg');

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

async function initDb() {
  await pool.query(`CREATE EXTENSION IF NOT EXISTS vector`);

  await pool.query(`
    CREATE TABLE IF NOT EXISTS documents (
      id UUID PRIMARY KEY,
      content TEXT NOT NULL,
      source TEXT NOT NULL,
      embedding VECTOR(3072)
    )
  `);

  console.log('Database ready');
}

module.exports = { pool, initDb };
</code></pre>
<p>The <code>VECTOR(3072)</code> dimension matches the output of Gemini's <code>gemini-embedding-001</code> model exactly. If you use a different embedding model in the future, check its output dimensions and update this number to match.</p>
<h2 id="heading-build-the-ingestion-pipeline">Build the Ingestion Pipeline</h2>
<p>Start with <code>embeddings.js</code>. This file is the bridge to both external APIs — Gemini for turning text into vectors, Groq for generating the final answer. Keeping both in one place means a single file to touch if you ever swap providers.</p>
<p><strong>src/embeddings.js:</strong></p>
<pre><code class="language-javascript">const GEMINI_KEY = process.env.GEMINI_API_KEY;
const GEMINI_BASE = 'https://generativelanguage.googleapis.com/v1/models';

async function embedText(text) {
  const res = await fetch(
    `${GEMINI_BASE}/gemini-embedding-001:embedContent?key=${GEMINI_KEY}`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ content: { parts: [{ text }] } }),
    }
  );
  const data = await res.json();
  if (!res.ok) throw new Error(JSON.stringify(data));
  return data.embedding.values;
}

async function generateAnswer(context, question) {
  const res = await fetch(
    'https://api.groq.com/openai/v1/chat/completions',
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${process.env.GROQ_API_KEY}`,
      },
      body: JSON.stringify({
        model: 'llama-3.1-8b-instant',
        messages: [
          {
            role: 'system',
            content: 'You are a helpful assistant. Answer the question using only the context provided. If the context does not contain enough information, say so clearly.',
          },
          {
            role: 'user',
            content: `Context:\n${context}\n\nQuestion: ${question}`,
          },
        ],
      }),
    }
  );
  const data = await res.json();
  if (!res.ok) throw new Error(JSON.stringify(data));
  return data.choices[0].message.content;
}

module.exports = { embedText, generateAnswer };
</code></pre>
<p>We're calling both APIs directly with Node.js's built-in <code>fetch</code> rather than the official SDKs. The reason is practical: Google's Node.js SDK routes requests through the <code>v1beta</code> endpoint by default, and <code>gemini-embedding-001</code> isn't available there — only on <code>v1</code>. Direct fetch sidesteps that entirely and keeps the dependency count low.</p>
<p><strong>src/ingest.js:</strong></p>
<pre><code class="language-javascript">const pdfParse = require('pdf-parse');
const { v4: uuidv4 } = require('uuid');
const { pool } = require('./db');
const { embedText } = require('./embeddings');

function chunkText(text, chunkSize = 500, overlap = 50) {
  const chunks = [];
  let start = 0;

  while (start &lt; text.length) {
    const end = Math.min(start + chunkSize, text.length);
    chunks.push(text.slice(start, end).trim());
    start += chunkSize - overlap;
  }

  return chunks.filter(chunk =&gt; chunk.length &gt; 50);
}

async function ingestDocument(buffer, filename) {
  const { text } = await pdfParse(buffer);
  const chunks = chunkText(text);

  console.log(`Processing ${chunks.length} chunks from "${filename}"`);

  for (const chunk of chunks) {
    const embedding = await embedText(chunk);

    await pool.query(
      `INSERT INTO documents (id, content, source, embedding)
       VALUES ($1, $2, $3, $4::vector)`,
      [uuidv4(), chunk, filename, JSON.stringify(embedding)]
    );
  }

  return chunks.length;
}

module.exports = { ingestDocument };
</code></pre>
<p>500 characters per chunk, with 50 characters of overlap between neighbours.</p>
<p>Why the overlap? Without it, a sentence that straddles a boundary gets split, half in one chunk, half in the next — and neither piece makes sense on its own when retrieved. The overlap keeps those boundary sentences intact.</p>
<p>For most technical docs, 500 is a good starting point. Dense legal or financial text tends to need something closer to 300.</p>
<h2 id="heading-build-the-query-pipeline">Build the Query Pipeline</h2>
<p><strong>src/query.js:</strong></p>
<pre><code class="language-javascript">const { pool } = require('./db');
const { embedText, generateAnswer } = require('./embeddings');

async function queryDocuments(question) {
  const questionEmbedding = await embedText(question);

  const { rows } = await pool.query(
    `SELECT content, source,
            1 - (embedding &lt;=&gt; $1::vector) AS similarity
     FROM documents
     ORDER BY embedding &lt;=&gt; $1::vector
     LIMIT 5`,
    [JSON.stringify(questionEmbedding)]
  );

  if (rows.length === 0) {
    return { answer: 'No relevant documents found.', sources: [] };
  }

  const context = rows.map(r =&gt; r.content).join('\n\n---\n\n');
  const answer = await generateAnswer(context, question);

  return {
    answer,
    sources: [...new Set(rows.map(r =&gt; r.source))],
    topSimilarity: parseFloat(rows[0].similarity).toFixed(3),
  };
}

module.exports = { queryDocuments };
</code></pre>
<p>The <code>&lt;=&gt;</code> operator is pgvector's cosine distance. Semantically similar text produces vectors that point in the same direction — so the distance between them is small. Flip that with <code>1 - distance</code> and you get a similarity score, where anything close to 1 means a strong match.</p>
<p>I found 0.7 to be a reliable threshold in my testing — chunks above that were almost always relevant. Anything below 0.5 and the retrieval was really stretching, pulling chunks that shared a keyword or two but weren't actually answering the question.</p>
<p>When that happens, the system prompt instruction ("if the context does not contain enough information, say so clearly") becomes important. A well-behaved model will tell the user it doesn't know rather than guess.</p>
<p>We also surface the source filename. Once you've ingested more than one document, users need to know whether that answer came from the architecture spec or the incident report.</p>
<h2 id="heading-build-the-chat-api-with-express">Build the Chat API with Express</h2>
<p><strong>src/index.js:</strong></p>
<pre><code class="language-javascript">require('dotenv').config();
const express = require('express');
const multer = require('multer');
const { initDb } = require('./db');
const { ingestDocument } = require('./ingest');
const { queryDocuments } = require('./query');

const app = express();
const upload = multer({ storage: multer.memoryStorage() });

app.use(express.json());

app.post('/ingest', upload.single('file'), async (req, res) =&gt; {
  if (!req.file) {
    return res.status(400).json({ error: 'No file uploaded' });
  }

  if (!req.file.mimetype.includes('pdf')) {
    return res.status(400).json({ error: 'Only PDF files are supported' });
  }

  try {
    const count = await ingestDocument(req.file.buffer, req.file.originalname);
    res.json({ message: `Ingested ${count} chunks from "${req.file.originalname}"` });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Ingestion failed', detail: err.message });
  }
});

app.post('/chat', async (req, res) =&gt; {
  const { question } = req.body;

  if (!question || typeof question !== 'string') {
    return res.status(400).json({ error: 'question is required' });
  }

  try {
    const result = await queryDocuments(question);
    res.json(result);
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Query failed', detail: err.message });
  }
});

const PORT = process.env.PORT || 3000;

initDb().then(() =&gt; {
  app.listen(PORT, () =&gt; {
    console.log(`RAG chatbot running on port ${PORT}`);
  });
});
</code></pre>
<p><code>memoryStorage()</code> keeps the uploaded file in a buffer instead of writing it to disk. We parse it and store the chunks immediately, so there's nothing to save.</p>
<h2 id="heading-test-the-chatbot">Test the Chatbot</h2>
<p>Start the server:</p>
<pre><code class="language-bash">npm run dev
</code></pre>
<p>You should see:</p>
<pre><code class="language-plaintext">Database ready
RAG chatbot running on port 3000
</code></pre>
<p>Upload a PDF. Any PDF works. I tested with a copy of a Node.js best practices guide:</p>
<pre><code class="language-bash"># Linux / macOS
curl -X POST http://localhost:3000/ingest -F "file=@your-document.pdf"

# Windows PowerShell
curl.exe -X POST http://localhost:3000/ingest -F "file=@your-document.pdf"
</code></pre>
<p>Response:</p>
<pre><code class="language-json">{ "message": "Ingested 142 chunks from \"your-document.pdf\"" }
</code></pre>
<p>Now ask a question:</p>
<pre><code class="language-bash"># Linux / macOS
curl -X POST http://localhost:3000/chat \
  -H "Content-Type: application/json" \
  -d '{ "question": "How should I handle errors in async functions?" }'

# Windows PowerShell
curl.exe -X POST http://localhost:3000/chat -H "Content-Type: application/json" -d "{\"question\": \"How should I handle errors in async functions?\"}"
</code></pre>
<p>Response:</p>
<pre><code class="language-json">{
  "answer": "For async functions in Node.js, wrap your logic in a try/catch block to handle rejected promises. In Express, pass the caught error to next(err) to trigger your error-handling middleware. Alternatively, you can create a wrapper function that wraps any async route handler in a promise and calls next on rejection, keeping your route handlers clean...",
  "sources": ["your-document.pdf"],
  "topSimilarity": "0.841"
}
</code></pre>
<p>The <code>topSimilarity</code> score tells you how well the retrieval went. Above 0.7 and the chunks pulled were genuinely relevant. Below 0.5, and the search was struggling: it found something, but not something that actually answers the question.</p>
<p>Try asking about something your PDF doesn't mention. If the system prompt is doing its job, the model should say it doesn't have enough information rather than making something up. That's the behaviour you want in production.</p>
<p>The repo includes two diagnostic scripts that are useful if anything isn't working:</p>
<ul>
<li><p><code>node test-keys.js</code> — tests both API keys live and reports whether each one succeeds</p>
</li>
<li><p><code>node list-models.js</code> — fetches the full list of Gemini models available to your API key</p>
</li>
</ul>
<p>Run these before diving into the troubleshooting section below.</p>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<p>Everything in this section is a real error I hit while building this. Nothing hypothetical.</p>
<h3 id="heading-port-5432-is-already-in-use">Port 5432 is already in use</h3>
<pre><code class="language-plaintext">Error: bind: address already in use
</code></pre>
<p>Something else — probably a local Postgres install — is already on that port. Two fixes are needed. First, in <code>docker-compose.yml</code>:</p>
<pre><code class="language-yaml">ports:
  - "5433:5432"
</code></pre>
<p>Second, update <code>DATABASE_URL</code> in <code>.env</code>:</p>
<pre><code class="language-plaintext">DATABASE_URL=postgresql://rag_user:rag_pass@localhost:5433/rag_db
</code></pre>
<p>The container itself still listens on 5432 internally. You're just changing which port your machine uses to reach it.</p>
<h3 id="heading-password-authentication-failed-for-user-raguser">Password authentication failed for user "rag_user"</h3>
<pre><code class="language-plaintext">Error: password authentication failed for user "rag_user"
</code></pre>
<p>The password Postgres was initialized with doesn't match what your app is sending. Open <code>.env</code> and compare <code>POSTGRES_PASSWORD</code> with the password embedded in <code>DATABASE_URL</code>. They need to be character-for-character identical.</p>
<p>After fixing the mismatch, the old volume still has the wrong password baked into it. You must destroy it and start fresh:</p>
<pre><code class="language-bash">docker compose down -v
docker compose up -d
</code></pre>
<p>The <code>-v</code> flag deletes the data volume. Postgres reinitializes on the next start with the credentials from your current <code>.env</code>.</p>
<h3 id="heading-gemini-model-not-found-404">Gemini model not found (404)</h3>
<pre><code class="language-json">{ "error": { "code": 404, "message": "models/text-embedding-004 is not found" } }
</code></pre>
<p>The Google AI model naming has changed. Older tutorials and blog posts reference model names that no longer exist on the v1 endpoint. The correct model for this stack is <code>gemini-embedding-001</code>. That's what this repo uses.</p>
<p>If you want to see every model available to your API key, run:</p>
<pre><code class="language-bash">node list-models.js
</code></pre>
<p>That script fetches the live list directly from the API so you're not guessing.</p>
<h3 id="heading-vector-dimension-mismatch">Vector dimension mismatch</h3>
<pre><code class="language-plaintext">ERROR: expected 768 dimensions, not 3072
</code></pre>
<p>This error appears when your database table was created with a different dimension count than what your embedding model produces. <code>gemini-embedding-001</code> outputs 3,072-dimensional vectors. The <code>documents</code> table in this tutorial uses <code>VECTOR(3072)</code> to match.</p>
<p>If you get this error, it means either an old table exists with the wrong dimension, or you changed embedding models without recreating the table. Drop the data volume and restart:</p>
<pre><code class="language-bash">docker compose down -v
docker compose up -d
</code></pre>
<h3 id="heading-vector-index-dimension-limit">Vector index dimension limit</h3>
<pre><code class="language-plaintext">ERROR: ivfflat index type only supports up to 2000 dimensions
</code></pre>
<p>pgvector's <code>ivfflat</code> and <code>hnsw</code> index types have a maximum dimension of 2000. Since <code>gemini-embedding-001</code> produces 3,072-dimensional vectors, neither index type works.</p>
<p>This tutorial drops the index and lets pgvector do a full scan — fine for development and any reasonably sized corpus. Scaling to thousands of documents in production? Pick a model under 2000 dimensions. OpenAI's <code>text-embedding-3-small</code> outputs 1536 and plays nicely with both index types.</p>
<h3 id="heading-port-3000-is-already-in-use">Port 3000 is already in use</h3>
<pre><code class="language-plaintext">Error: EADDRINUSE: address already in use :::3000
</code></pre>
<p>Some other process got there first. Swap the port number in <code>.env</code>:</p>
<pre><code class="language-plaintext">PORT=3002
</code></pre>
<p>Save it and restart the server.</p>
<h3 id="heading-gemini-generation-returns-quota-exceeded-limit-0">Gemini generation returns quota exceeded (limit: 0)</h3>
<pre><code class="language-json">{ "error": { "status": "RESOURCE_EXHAUSTED", "message": "Quota exceeded for quota metric ... with limit 0" } }
</code></pre>
<p>That <code>limit 0</code> means Google has switched off free generation in your country entirely — not that you've used it up. I hit this myself while testing from Pakistan.</p>
<p>That's exactly why this tutorial uses Groq instead. Make sure <code>GROQ_API_KEY</code> is in your <code>.env</code> and that <code>generateAnswer</code> in <code>src/embeddings.js</code> is pointing at <code>api.groq.com</code>.</p>
<p>To verify both keys work, run:</p>
<pre><code class="language-bash">node test-keys.js
</code></pre>
<p>It tests the Gemini embedding endpoint and the Groq generation endpoint independently and reports whether each succeeds.</p>
<h3 id="heading-nodemon-doesnt-pick-up-changes-to-env">nodemon doesn't pick up changes to <code>.env</code></h3>
<p>nodemon only watches <code>.js</code> files — <code>.env</code> changes don't trigger a restart. Switch to the terminal running the server and type <code>rs</code>, then hit Enter. That forces a restart and picks up whatever you changed.</p>
<h3 id="heading-curl-doesnt-work-in-windows-powershell"><code>curl</code> doesn't work in Windows PowerShell</h3>
<pre><code class="language-plaintext">curl : The term 'curl' is not recognized
</code></pre>
<p>or</p>
<pre><code class="language-plaintext">curl : Cannot bind parameter because parameter 'Method' is specified more than once
</code></pre>
<p>PowerShell has a built-in <code>curl</code> alias that points to <code>Invoke-WebRequest</code> — completely different flags, completely different behaviour. Add <code>.exe</code> and you bypass the alias and hit the real binary.</p>
<p>So instead of <code>curl</code>, type <code>curl.exe</code>:</p>
<pre><code class="language-powershell"># Ingest
curl.exe -X POST http://localhost:3000/ingest -F "file=@your-document.pdf"

# Chat
curl.exe -X POST http://localhost:3000/chat -H "Content-Type: application/json" -d "{\"question\": \"How do I handle async errors?\"}"
</code></pre>
<p>That <code>.exe</code> is the whole fix.</p>
<h3 id="heading-docker-desktop-stopped-running">Docker Desktop stopped running</h3>
<p>Docker Desktop doesn't start automatically after a reboot on most setups. If your Docker commands suddenly fail with connection errors, that's probably why. Open Docker Desktop, wait until it says "Engine running", then try again.</p>
<h2 id="heading-how-to-swap-in-openai">How to Swap in OpenAI</h2>
<p>If you want to use the OpenAI API instead of Gemini, it's three changes.</p>
<p>1. Install the OpenAI SDK:</p>
<pre><code class="language-bash">npm install openai
</code></pre>
<p>2. Replace <code>src/embeddings.js</code> entirely:</p>
<pre><code class="language-javascript">const OpenAI = require('openai');

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

async function embedText(text) {
  const result = await client.embeddings.create({
    model: 'text-embedding-3-small',
    input: text,
  });
  return result.data[0].embedding;
}

async function generateAnswer(context, question) {
  const result = await client.chat.completions.create({
    model: 'gpt-4o-mini',
    messages: [
      { role: 'system', content: 'Answer only from the context provided. If the context is insufficient, say so.' },
      { role: 'user', content: `Context:\n${context}\n\nQuestion: ${question}` },
    ],
  });
  return result.choices[0].message.content;
}

module.exports = { embedText, generateAnswer };
</code></pre>
<p>3. Update the vector dimension in <code>src/db.js</code>:</p>
<p>Open <code>db.js</code> and swap <code>VECTOR(3072)</code> for <code>VECTOR(1536)</code> — that's the output size of <code>text-embedding-3-small</code>. Then kill the volume so the table gets recreated with the right dimensions:</p>
<pre><code class="language-bash">docker compose down -v
docker compose up -d
</code></pre>
<p>Nothing else needs touching. The ingestion and query logic works the same regardless of which model you plugged in.</p>
<h2 id="heading-what-to-build-next">What to Build Next</h2>
<p>What you've built works. But there are some gaps that come up quickly once you put it in front of real users.</p>
<p>The most noticeable one is <strong>streaming</strong>. Right now <code>/chat</code> holds the connection open until Groq finishes generating the full answer, then returns everything at once. On a short question that's fine. On a longer one, the user stares at nothing for a few seconds and wonders if the request hung.</p>
<p>The Groq API supports streaming — add <code>stream: true</code> to the request body and tokens start coming back as they're generated. Piping those through Express with <code>res.write()</code> is maybe 15 minutes of work and the difference in feel is immediate.</p>
<p><strong>Metadata filtering</strong> is the second thing you'll want. Once you've loaded more than a few documents, queries bleed across everything: ask about the API spec and you'll get chunks from the onboarding guide too.</p>
<p>The fix is a <code>metadata JSONB</code> column where you store the document ID on ingest, then add <code>WHERE metadata-&gt;&gt;'doc_id' = $1</code> to the similarity query. Expose it as an optional body field on <code>/chat</code>: <code>{ "question": "...", "docId": "api-spec-v2" }</code>. Users get scoped results, and you get much cleaner answers.</p>
<p>When your corpus grows into the hundreds of documents, look at <strong>re-ranking</strong>. Vector similarity retrieval is fast but approximate — it finds chunks that are semantically close to the question, not necessarily the ones that most directly answer it.</p>
<p>The pattern is: retrieve the top 20 by cosine distance, then run a cross-encoder over them to re-score by actual relevance, then take the best 5 from that second pass. LangChain.js has a cross-encoder wrapper if you don't want to implement it yourself.</p>
<p>The last thing most people forget until they actually need it is <strong>document management</strong> — the ability to list what's ingested, delete a specific file, and re-ingest an updated version.</p>
<p>A <code>DELETE FROM documents WHERE source = $1</code> handles the delete case. Add a <code>GET /documents</code> endpoint that queries <code>SELECT DISTINCT source FROM documents</code> and you have a complete enough API for real use.</p>
<p>RAG isn't magic. It's a well-scoped retrieval problem combined with a language model that's been told to stay within its lane.</p>
<p>The quality of your answers depends on three things: how cleanly your PDFs parse, how well your chunk size fits the content type, and how clearly your system prompt instructs the model to say "I don't know" rather than guess. Get those right and you've built something genuinely useful: the kind of thing that saves a new engineer's first two weeks.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Containerize a Node.js Application with Docker and Deploy with GitHub Actions ]]>
                </title>
                <description>
                    <![CDATA[ If you've been building Node.js projects, you've probably had an experience like this. The project runs fine on your machine, but when you push it to a server, something breaks. Maybe it's a different ]]>
                </description>
                <link>https://www.freecodecamp.org/news/containerize-a-node-js-app-with-docker-and-deploy-with-github-actions/</link>
                <guid isPermaLink="false">6a569b9cbd138d774dee2042</guid>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub Actions ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker compose ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker-compose.yml ]]>
                    </category>
                
                    <category>
                        <![CDATA[ containerization ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Backend Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tutorial ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Tue, 14 Jul 2026 20:27:08 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/343864e6-5319-4378-a2b1-4955e38ad6d8.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've been building <a href="https://www.freecodecamp.org/news/role-based-access-control-nodejs-rest-api-jwt/">Node.js projects</a>, you've probably had an experience like this. The project runs fine on your machine, but when you push it to a server, something breaks.</p>
<p>Maybe it's a different Node version, maybe an environment variable is missing, or maybe a system dependency doesn't match. You spend an hour debugging something that was never actually a code problem.</p>
<p>Docker fixes this at the root. With Docker, you stop shipping just code. The Node version, dependencies, and config all travel inside the container. Your laptop, a CI server, a production VM — it behaves the same on all of them. No more environment surprises.</p>
<p>In this tutorial, we'll go through all this step by step: a multi-stage Dockerfile, using Docker Compose with PostgreSQL for local development, and a GitHub Actions workflow that pushes a fresh image to Docker Hub on every merge to <code>main</code>.</p>
<p>The complete code for this tutorial is available on <a href="https://github.com/ziaongit/nodejs-docker-cicd">GitHub</a>.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-sample-application">The Sample Application</a></p>
</li>
<li><p><a href="#heading-writing-the-dockerfile">Writing the Dockerfile</a></p>
</li>
<li><p><a href="#heading-the-dockerignore-file">The .dockerignore File</a></p>
</li>
<li><p><a href="#heading-the-gitignore-file">The .gitignore File</a></p>
</li>
<li><p><a href="#heading-build-and-test-the-image-locally">Build and Test the Image Locally</a></p>
</li>
<li><p><a href="#heading-docker-compose-for-local-development">Docker Compose for Local Development</a></p>
</li>
<li><p><a href="#heading-automate-the-build-with-github-actions">Automate the Build with GitHub Actions</a></p>
</li>
<li><p><a href="#heading-deploying-the-image">Deploying the Image</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js 18+</p>
</li>
<li><p>Docker Desktop, which you can download at <a href="https://docs.docker.com/get-docker/">docs.docker.com/get-docker</a>. Windows users need WSL 2 before Docker starts. Open PowerShell as Administrator and run <code>wsl --install</code>. After the restart, Docker Desktop will install without issues.</p>
</li>
<li><p>A GitHub account</p>
</li>
<li><p>A Docker Hub account (free at <a href="https://hub.docker.com">hub.docker.com</a>)</p>
</li>
<li><p>Some Express.js experience helps, but isn't required</p>
</li>
</ul>
<h2 id="heading-the-sample-application">The Sample Application</h2>
<p>We're building a task management API with Express and PostgreSQL. Keep in mind the app is just a vehicle to teach you how this works. The Dockerfile and pipeline we set up here work the same way for any Node.js project.</p>
<p>Create the project:</p>
<pre><code class="language-bash">mkdir nodejs-docker-cicd &amp;&amp; cd nodejs-docker-cicd
npm init -y
npm install express pg dotenv
npm install --save-dev nodemon
</code></pre>
<p>Create <code>src/index.js</code>:</p>
<pre><code class="language-javascript">const express = require('express');
const { Pool } = require('pg');
require('dotenv').config();

const app = express();
app.use(express.json());

const pool = new Pool({
  host: process.env.DB_HOST,
  port: process.env.DB_PORT,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
});

// Create table on startup
pool.query(`
  CREATE TABLE IF NOT EXISTS tasks (
    id SERIAL PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    completed BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT NOW()
  )
`).catch(console.error);

// Health check — required for Docker HEALTHCHECK and load balancers
app.get('/health', (req, res) =&gt; {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

app.get('/tasks', async (req, res) =&gt; {
  try {
    const result = await pool.query('SELECT * FROM tasks ORDER BY created_at DESC');
    res.json(result.rows);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.post('/tasks', async (req, res) =&gt; {
  const { title } = req.body;
  if (!title) return res.status(400).json({ error: 'Title is required' });
  try {
    const result = await pool.query(
      'INSERT INTO tasks (title) VALUES ($1) RETURNING *',
      [title]
    );
    res.status(201).json(result.rows[0]);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.patch('/tasks/:id', async (req, res) =&gt; {
  const { id } = req.params;
  const { completed } = req.body;
  try {
    const result = await pool.query(
      'UPDATE tasks SET completed = $1 WHERE id = $2 RETURNING *',
      [completed, id]
    );
    if (result.rows.length === 0) return res.status(404).json({ error: 'Task not found' });
    res.json(result.rows[0]);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () =&gt; console.log(`Server running on port ${PORT}`));
</code></pre>
<p>Open <code>package.json</code> and update the <code>"scripts"</code> section:</p>
<pre><code class="language-json">"scripts": {
  "start": "node src/index.js",
  "dev": "nodemon src/index.js"
}
</code></pre>
<p><code>npm start</code> runs the app directly with Node. <code>npm run dev</code> uses nodemon so the server restarts automatically when you edit a file.</p>
<p>For running without Docker, create a <code>.env</code> file:</p>
<pre><code class="language-plaintext">DB_HOST=localhost
DB_PORT=5432
DB_NAME=tasksdb
DB_USER=postgres
DB_PASSWORD=yourpassword
PORT=3000
</code></pre>
<p>Notice that all database credentials come from environment variables rather than being hardcoded. Swap the variables, and the same image runs against your local database or a production one — no code changes needed. The <code>/health</code> endpoint is what Docker pings to know the app is actually handling requests.</p>
<h2 id="heading-writing-the-dockerfile">Writing the Dockerfile</h2>
<p>Before touching the Dockerfile, there are two terms you'll keep seeing. An <strong>image</strong> is a packaged, immutable version of your app — Node runtime, code, dependencies, everything together in one artifact. A <strong>container</strong> is a running instance of that image. One image, many containers, any machine.</p>
<p>Here's the Dockerfile we'll use:</p>
<pre><code class="language-dockerfile"># ── Stage 1: Install dependencies ──────────────────────────────────────────
FROM node:18-alpine AS builder

WORKDIR /app

# Copy package files first — Docker caches this layer separately.
# If you only change src code (not package.json), Docker skips npm ci on rebuild.
COPY package*.json ./
RUN npm ci

COPY . .


# ── Stage 2: Production image ───────────────────────────────────────────────
FROM node:18-alpine AS production

# Create a non-root user — running as root inside a container is a security risk
RUN addgroup -g 1001 -S nodejs &amp;&amp; \
    adduser -S nodeuser -u 1001

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

# Copy only the source code from the builder stage (not node_modules or dev files)
COPY --from=builder /app/src ./src

RUN chown -R nodeuser:nodejs /app
USER nodeuser

EXPOSE 3000

# Docker will ping /health every 30s. If it fails 3 times, the container is marked unhealthy.
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

CMD ["node", "src/index.js"]
</code></pre>
<p>This is a multi-stage build. The first stage (<code>builder</code>) installs everything, including dev dependencies. The second stage (<code>production</code>) starts fresh and only copies what the app needs to run. Nodemon, test frameworks, and anything else dev-only never make it into the final image.</p>
<p>The size difference is real. A <code>node:18</code> Debian image is over 950MB. Switch to <code>node:18-alpine</code> and cut out the dev dependencies, and the final image lands around 150–200MB instead. A smaller image means faster pushes and faster deploys.</p>
<p><code>npm ci</code> instead of <code>npm install</code> is a deliberate choice for CI/CD. It reads exact versions from <code>package-lock.json</code> and fails hard if the lockfile doesn't match <code>package.json</code>. Every build on every machine installs the exact same versions — no surprises from a dependency that quietly updated overnight.</p>
<p>The <code>nodeuser</code> account exists because containers run as root by default. That's fine until something goes wrong. A non-root user means that an attacker who gets inside the container can't just do whatever they want.</p>
<h2 id="heading-the-dockerignore-file">The <code>.dockerignore</code> File</h2>
<p>Create <code>.dockerignore</code> before building:</p>
<pre><code class="language-plaintext">node_modules
npm-debug.log
.env
.git
.gitignore
README.md
Dockerfile
.dockerignore
</code></pre>
<p>The <code>node_modules</code> exclusion is the critical one. Your local modules were compiled for your operating system — macOS or Windows binaries won't work inside a Linux container. Excluding them means Docker installs fresh modules during the build, compiled for the correct platform. Without this exclusion, you'd either copy broken binaries into the image or waste time uploading hundreds of megabytes to the build context.</p>
<p>Never put <code>.env</code> in an image. Passwords, API keys, anything sensitive — those go in at runtime as environment variables, never inside the image itself.</p>
<h2 id="heading-the-gitignore-file">The <code>.gitignore</code> File</h2>
<p>One more thing before the first commit: a <code>.gitignore</code>. You don't want <code>node_modules</code> or <code>.env</code> tracked:</p>
<pre><code class="language-plaintext">node_modules/
.env
.env.local
npm-debug.log*
logs/
.DS_Store
Thumbs.db
.vscode/
.idea/
dist/
build/
</code></pre>
<h2 id="heading-build-and-test-the-image-locally">Build and Test the Image Locally</h2>
<p>Open Docker Desktop first and give it a moment. On Windows, you'll see a whale icon in the taskbar that animates while the engine is starting up. Once it goes still, you're good to run Docker commands. If you try to run Docker before the engine is up, you'll hit this:</p>
<pre><code class="language-plaintext">ERROR: Error response from daemon: Docker Desktop is unable to start
</code></pre>
<p>If that happens, quit Docker Desktop. Open PowerShell as Administrator, run <code>wsl --update</code>, and restart. Then go to Control Panel → Programs → Turn Windows features on or off. Both Hyper-V and Virtual Machine Platform need to be checked. After the restart, Docker Desktop should come up fine.</p>
<p>It's worth knowing about this error too:</p>
<pre><code class="language-plaintext">docker : The term 'docker' is not recognized as the name of a cmdlet, function,
script file, or operable program.
</code></pre>
<p>This means that Docker Desktop isn't running or isn't installed. Open it from the Start menu and wait.</p>
<p>Run the build:</p>
<pre><code class="language-bash">docker build -t nodejs-docker-cicd:latest .
</code></pre>
<p>The first time takes roughly 30 seconds since Docker has to pull <code>node:18-alpine</code> from the internet. Once that's cached, subsequent builds are much quicker. Both stages will scroll by:</p>
<pre><code class="language-plaintext">[+] Building 33.1s (17/17) FINISHED
 =&gt; [builder 1/5] FROM docker.io/library/node:18-alpine       20.9s
 =&gt; [builder 4/5] RUN npm ci                                   3.5s
 =&gt; [production 5/7] RUN npm ci --only=production              3.2s
 =&gt; [production 7/7] RUN chown -R nodeuser:nodejs /app         3.2s
 =&gt; exporting to image                                         1.5s
 =&gt; =&gt; naming to docker.io/library/nodejs-docker-cicd:latest     0.0s
</code></pre>
<p>When you see <code>(17/17) FINISHED</code> the image is built. Check the size:</p>
<pre><code class="language-bash">docker images nodejs-docker-cicd
</code></pre>
<pre><code class="language-plaintext">IMAGE                     ID             DISK USAGE   CONTENT SIZE
nodejs-docker-cicd:latest   c9eed311d999        198MB         47.5MB
</code></pre>
<p><strong>CONTENT SIZE</strong> (47.5MB) is the compressed size that gets pushed to Docker Hub. <strong>DISK USAGE</strong> (198MB) is what it takes up on disk locally. Compare that to a <code>node:18</code> Debian image at 950MB+, and you can see why the Alpine base and multi-stage approach matter.</p>
<p>On subsequent builds, Docker reuses cached layers. Edit only your source files without touching <code>package.json</code> and the <code>npm ci</code> step gets skipped completely. That 33-second first build becomes 3 seconds.</p>
<h2 id="heading-docker-compose-for-local-development">Docker Compose for Local Development</h2>
<p>The app needs a database. Setting up PostgreSQL locally means every developer who clones the repo has to do it, too. Docker Compose handles this: one file defines both services, and one command starts them.</p>
<p>Create <code>docker-compose.yml</code>:</p>
<pre><code class="language-yaml">services:
  app:
    build:
      context: .
      target: production
    ports:
      - '3000:3000'
    environment:
      DB_HOST: postgres
      DB_PORT: 5432
      DB_NAME: tasksdb
      DB_USER: postgres
      DB_PASSWORD: postgres
      PORT: 3000
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: tasksdb
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - '5432:5432'
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U postgres']
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:
</code></pre>
<p>A few things worth pointing out. <code>DB_HOST</code> is set to <code>postgres</code>. That's the service name, not <code>localhost</code>. Containers on the same Docker network reach each other by service name. Put <code>localhost</code> there and the app tries to connect to itself.</p>
<p><code>depends_on</code> with <code>condition: service_healthy</code> holds the app back until Postgres actually passes its health check. Skip this and the app starts, tries to connect to a database that isn't ready yet, and crashes. The health check pings <code>pg_isready</code> every 5 seconds. Once it gets a green response, the app container starts.</p>
<p>The named volume <code>postgres_data</code> keeps your data alive between restarts. Run <code>docker compose down</code> and the data is still there next time. Add <code>--volumes</code> to wipe it clean.</p>
<p>Start both services:</p>
<pre><code class="language-bash">docker compose up --build
</code></pre>
<p>You'll see PostgreSQL initialize and then the app start. Once you see <code>Server running on port 3000</code> in the logs, the stack is up.</p>
<p>Open a second terminal to test — leave the compose logs running in the first one.</p>
<p><strong>Linux/macOS:</strong></p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn Docker"}'

curl http://localhost:3000/tasks

curl http://localhost:3000/health
</code></pre>
<p><strong>Windows PowerShell:</strong> Typing <code>curl</code> in PowerShell runs <code>Invoke-WebRequest</code>, not actual curl. Run <code>curl.exe</code> instead. For JSON bodies, write to a file first:</p>
<pre><code class="language-powershell">'{"title": "Learn Docker"}' | Set-Content body.json
curl.exe -X POST http://localhost:3000/tasks -H "Content-Type: application/json" --data `@body.json

curl.exe http://localhost:3000/tasks

curl.exe http://localhost:3000/health
</code></pre>
<p>The backtick before <code>@body.json</code> is necessary. PowerShell would otherwise try to interpret <code>@</code> as a splatting operator rather than passing it to curl as a filename prefix.</p>
<p>You should see responses like these:</p>
<pre><code class="language-json"># POST /tasks
{"id":1,"title":"Learn Docker","completed":false,"created_at":"2026-07-09T22:21:17.073Z"}

# GET /tasks
[{"id":1,"title":"Learn Docker","completed":false,"created_at":"2026-07-09T22:21:17.073Z"}]

# GET /health
{"status":"ok","timestamp":"2026-07-09T22:11:44.700Z"}
</code></pre>
<p>The task hit PostgreSQL in one container and came back through the app. <code>Ctrl+C</code> in the compose terminal stops both.</p>
<h2 id="heading-automate-the-build-with-github-actions">Automate the Build with GitHub Actions</h2>
<p>The image works locally, so it's time to stop doing this by hand.</p>
<h3 id="heading-step-1-create-a-docker-hub-access-token">Step 1: Create a Docker Hub Access Token</h3>
<p>Go to <a href="https://hub.docker.com">hub.docker.com</a> and then Account Settings → Security → New Access Token. Set permission to Read &amp; Write, as read-only breaks the push. The token appears once, so copy it before closing the page.</p>
<p><strong>Security warning:</strong> Don't paste this token into a chat, email, or commit. If you expose it by accident, delete it immediately, then make a new one.</p>
<h3 id="heading-step-2-add-secrets-to-your-github-repository">Step 2: Add Secrets to Your GitHub Repository</h3>
<p>Head to Settings → Secrets and variables → Actions in your repo and add:</p>
<ul>
<li><p><code>DOCKERHUB_USERNAME</code> — your Docker Hub username</p>
</li>
<li><p><code>DOCKERHUB_TOKEN</code> — paste the token here, nowhere else</p>
</li>
</ul>
<p>If you ran into <code>Error: Username and password required</code>, the secrets either aren't saved yet or the names are typed wrong. Both are case-sensitive.</p>
<p>A Node 20 deprecation warning in the logs is normal. It comes from the Docker actions internally, not your code.</p>
<h3 id="heading-step-3-create-the-workflow-file">Step 3: Create the Workflow File</h3>
<p>Create <code>.github/workflows/docker-publish.yml</code>:</p>
<pre><code class="language-yaml">name: Build and Push Docker Image

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/nodejs-docker-cicd

jobs:
  build-and-push:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to Docker Hub
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          target: production
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
</code></pre>
<p>The login step has <code>if: github.event_name != 'pull_request'</code>. This skips authentication on pull requests. PRs from forks don't have access to your secrets, so trying to log in would just fail. The build still runs on PRs to validate your Dockerfile, but the image isn't pushed.</p>
<p>The metadata action generates two tags on every merge to <code>main</code>: <code>latest</code> and a short commit SHA like <code>sha-a1b2c3d</code>. The SHA tag is what makes rollbacks practical. If <code>latest</code> breaks in production, you can pull any previous <code>sha-</code> tag and you're back to a known-good state in seconds.</p>
<p>The <code>cache-from/cache-to: type=gha</code> lines store Docker's layer cache in GitHub Actions' built-in cache. The first run builds everything from scratch. After that, unchanged layers are pulled from cache rather than rebuilt. On a typical Node.js app this brings build time from 2–3 minutes down to under 30 seconds.</p>
<h3 id="heading-push-and-watch-it-run">Push and Watch it Run</h3>
<pre><code class="language-bash">git add .
git commit -m "Add Docker configuration and GitHub Actions workflow"
git push origin main
</code></pre>
<p>Go to your repo's <strong>Actions</strong> tab. You'll see the workflow running in real time. Each step turns green as it completes:</p>
<pre><code class="language-plaintext">✅ Checkout code
✅ Set up Docker Buildx
✅ Log in to Docker Hub
✅ Extract metadata
✅ Build and push
</code></pre>
<p>Green across the board means your image is live on Docker Hub — two tags, <code>latest</code> and a commit SHA like <code>sha-a1b2c3d</code>. Every push to <code>main</code> from here builds and ships automatically.</p>
<h2 id="heading-deploying-the-image">Deploying the Image</h2>
<p>With your image on Docker Hub, you can deploy it to any infrastructure:</p>
<p><strong>Any VPS or server:</strong></p>
<pre><code class="language-bash">docker pull yourusername/nodejs-docker-cicd:latest
docker run -d -p 3000:3000 \
  -e DB_HOST=your-db-host \
  -e DB_NAME=tasksdb \
  -e DB_USER=postgres \
  -e DB_PASSWORD=yourpassword \
  yourusername/nodejs-docker-cicd:latest
</code></pre>
<p><strong>Railway</strong> — Connect your Docker Hub image in the Railway dashboard and it deploys on the next push.</p>
<p><strong>Fly.io</strong> — Run <code>fly launch</code> pointing at your Dockerfile and Fly handles the rest.</p>
<p><strong>Render</strong> — Paste your Docker Hub image URL into the Render service settings.</p>
<p>Each push to <code>main</code> runs the workflow. New image goes to Docker Hub, platform picks it up — that's your deployment handled.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>What started as a local Node.js app now runs in a container. You get the same behavior on any machine, real PostgreSQL in development, and a pipeline that builds and ships to Docker Hub without you doing anything after the push.</p>
<p>The multi-stage build keeps the image lean — dev tools stay out, non-root user, health check baked in. Compose gets the full stack up with one command for anyone who clones the repo. The SHA tag on every GitHub Actions build means rolling back is just a matter of pulling an older tag.</p>
<p>These same patterns (multi-stage builds, Compose for local development, automated image publishing) are used across the industry for production Node.js deployments. Pick up these patterns once and they follow you to every project.</p>
<p>From here, you can extend the pipeline: drop a test step in before the build, or add multi-platform support if you're targeting ARM. Once Docker Compose starts feeling limiting in production, that's usually when Kubernetes enters the picture.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Implement Role-Based Access Control in a Node.js REST API with JWT ]]>
                </title>
                <description>
                    <![CDATA[ The first time I built an API without thinking about roles, I gave every logged-in user the same access. It worked fine until a regular user accidentally hit a delete endpoint and wiped test data. Tha ]]>
                </description>
                <link>https://www.freecodecamp.org/news/role-based-access-control-nodejs-rest-api-jwt/</link>
                <guid isPermaLink="false">6a4fb4570140649a4367b476</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Thu, 09 Jul 2026 14:46:47 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/d742efbd-8170-4fb6-8851-1f7c6ef9125e.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The first time I built an API without thinking about roles, I gave every logged-in user the same access. It worked fine until a regular user accidentally hit a delete endpoint and wiped test data. That was the day I actually sat down and learned RBAC properly.</p>
<p>Role-Based Access Control sounds fancy, but the idea is simple: what you can do depends on <em>who you are</em>, not just <em>that you're logged in</em>. An admin deletes users. An editor creates posts. A regular user just reads. Same app, completely different experience depending on who's asking.</p>
<p>That's what we're building here. A REST API with three roles: JWT to carry those roles on every request, and a pair of middleware functions that check permissions before your route handlers even run. There's no database hit per request, and no if/else soup in your business logic.</p>
<p>By the end, you'll have three working roles (<code>admin</code>, <code>editor</code>, <code>user</code>) each locked to their own endpoints. More importantly, the pattern is transferable: once it clicks, you'll wire it into your next project without needing a tutorial.</p>
<p><strong>Full source code on GitHub:</strong> <a href="https://github.com/ziaongit/nodejs-rbac-jwt-api">github.com/ziaongit/nodejs-rbac-jwt-api</a></p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-learn">What You'll Learn</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-well-build">What We'll Build</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-setting-up-the-in-memory-data-store">Setting Up the In-Memory Data Store</a></p>
</li>
<li><p><a href="#heading-building-the-auth-routes">Building the Auth Routes</a></p>
</li>
<li><p><a href="#heading-building-the-rbac-middleware">Building the RBAC Middleware</a></p>
</li>
<li><p><a href="#heading-building-the-protected-routes">Building the Protected Routes</a></p>
</li>
<li><p><a href="#heading-putting-it-all-together">Putting It All Together</a></p>
</li>
<li><p><a href="#heading-testing-the-api">Testing the API</a></p>
</li>
<li><p><a href="#heading-key-takeaways">Key Takeaways</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-youll-learn">What You'll Learn</h2>
<ul>
<li><p>What RBAC is and how it differs from basic authentication</p>
</li>
<li><p>How to embed roles in JWT payloads</p>
</li>
<li><p>How to write reusable Express middleware for token verification and role checking</p>
</li>
<li><p>How to protect API routes based on user roles</p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js (v18+) installed</p>
</li>
<li><p>Basic knowledge of Express.js</p>
</li>
<li><p>Familiarity with how JWTs work (we'll cover the relevant parts)</p>
</li>
<li><p>npm installed</p>
</li>
</ul>
<h2 id="heading-what-well-build">What We'll Build</h2>
<p>We'll build a REST API for a simple content management system with three user roles:</p>
<table>
<thead>
<tr>
<th>Role</th>
<th>Permissions</th>
</tr>
</thead>
<tbody><tr>
<td><code>user</code></td>
<td>Read content</td>
</tr>
<tr>
<td><code>editor</code></td>
<td>Read + create content</td>
</tr>
<tr>
<td><code>admin</code></td>
<td>Full access — read, create, delete content, manage users</td>
</tr>
</tbody></table>
<p>The API will expose these endpoints:</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Endpoint</th>
<th>Access</th>
</tr>
</thead>
<tbody><tr>
<td>POST</td>
<td>/api/auth/register</td>
<td>Public</td>
</tr>
<tr>
<td>POST</td>
<td>/api/auth/login</td>
<td>Public</td>
</tr>
<tr>
<td>GET</td>
<td>/api/content</td>
<td>user, editor, admin</td>
</tr>
<tr>
<td>POST</td>
<td>/api/content</td>
<td>editor, admin</td>
</tr>
<tr>
<td>DELETE</td>
<td>/api/content/:id</td>
<td>admin only</td>
</tr>
<tr>
<td>GET</td>
<td>/api/admin/users</td>
<td>admin only</td>
</tr>
</tbody></table>
<h2 id="heading-project-setup">Project Setup</h2>
<p>Create a new folder and initialize the project:</p>
<pre><code class="language-bash">mkdir nodejs-rbac-jwt-api
cd nodejs-rbac-jwt-api
npm init -y
</code></pre>
<p>Install the dependencies:</p>
<pre><code class="language-bash">npm install express jsonwebtoken bcryptjs dotenv
npm install --save-dev nodemon
</code></pre>
<p>Here's what each package does:</p>
<ul>
<li><p><strong>express</strong>: web framework for building the API</p>
</li>
<li><p><strong>jsonwebtoken</strong>: creates and verifies JWTs</p>
</li>
<li><p><strong>bcryptjs</strong>: securely hashes passwords</p>
</li>
<li><p><strong>dotenv</strong>: reads your <code>.env</code> file so you're not hardcoding secrets in your source code</p>
</li>
</ul>
<p>Update <code>package.json</code> to add start scripts:</p>
<pre><code class="language-json">"scripts": {
  "start": "node src/app.js",
  "dev": "nodemon src/app.js"
}
</code></pre>
<p>Create the project structure:</p>
<pre><code class="language-plaintext">nodejs-rbac-jwt-api/
├── src/
│   ├── middleware/
│   │   └── auth.js
│   ├── routes/
│   │   ├── auth.js
│   │   ├── content.js
│   │   └── admin.js
│   ├── data/
│   │   └── users.js
│   └── app.js
├── .env
├── .env.example
└── package.json
</code></pre>
<p>Create your <code>.env</code> file:</p>
<pre><code class="language-plaintext">JWT_SECRET=your_super_secret_key_change_this_in_production
PORT=3000
</code></pre>
<p><strong>Important:</strong> Never commit your <code>.env</code> file to version control. Add it to <code>.gitignore</code>.</p>
<h2 id="heading-setting-up-the-in-memory-data-store">Setting Up the In-Memory Data Store</h2>
<p>We don't have a database here, just an array in memory. The point was to keep the focus on RBAC, not spend half the tutorial on database config. In a real project, swap the array for whatever database you're already using.</p>
<p>Create <code>src/data/users.js</code>:</p>
<pre><code class="language-javascript">// In-memory users store
// In production, replace this with a real database (MongoDB, PostgreSQL, etc.)
const users = [];

const findUserByEmail = (email) =&gt; users.find((u) =&gt; u.email === email);
const findUserById = (id) =&gt; users.find((u) =&gt; u.id === id);
const createUser = (user) =&gt; {
  users.push(user);
  return user;
};
const getAllUsers = () =&gt; users.map(({ password, ...user }) =&gt; user);

module.exports = { findUserByEmail, findUserById, createUser, getAllUsers };
</code></pre>
<p>One thing worth noting: <code>getAllUsers</code> uses destructuring to drop the password before returning anything. Never send password fields in API responses, even hashed ones.</p>
<h2 id="heading-building-the-auth-routes">Building the Auth Routes</h2>
<p>The auth routes handle registration and login. Login is where roles first enter the picture — we embed the user's role directly into the JWT payload.</p>
<p>Create <code>src/routes/auth.js</code>:</p>
<pre><code class="language-javascript">const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { findUserByEmail, createUser } = require('../data/users');

const router = express.Router();

// POST /api/auth/register
router.post('/register', async (req, res) =&gt; {
  const { name, email, password, role } = req.body;

  if (!name || !email || !password) {
    return res.status(400).json({ message: 'Name, email, and password are required' });
  }

  if (findUserByEmail(email)) {
    return res.status(409).json({ message: 'Email already registered' });
  }

  // Only allow valid roles — default to 'user' if none provided
  const validRoles = ['user', 'editor', 'admin'];
  const assignedRole = validRoles.includes(role) ? role : 'user';

  const hashedPassword = await bcrypt.hash(password, 10);

  const newUser = {
    id: Date.now().toString(),
    name,
    email,
    password: hashedPassword,
    role: assignedRole,
  };

  createUser(newUser);

  res.status(201).json({
    message: 'User registered successfully',
    user: {
      id: newUser.id,
      name: newUser.name,
      email: newUser.email,
      role: newUser.role,
    },
  });
});

// POST /api/auth/login
router.post('/login', async (req, res) =&gt; {
  const { email, password } = req.body;

  if (!email || !password) {
    return res.status(400).json({ message: 'Email and password are required' });
  }

  const user = findUserByEmail(email);
  if (!user) {
    return res.status(401).json({ message: 'Invalid credentials' });
  }

  const isMatch = await bcrypt.compare(password, user.password);
  if (!isMatch) {
    return res.status(401).json({ message: 'Invalid credentials' });
  }

  // Issue JWT — embed role in the payload
  const token = jwt.sign(
    {
      id: user.id,
      email: user.email,
      role: user.role,   // ← This is the key part for RBAC
    },
    process.env.JWT_SECRET,
    { expiresIn: '24h' }
  );

  res.json({
    message: 'Login successful',
    token,
  });
});

module.exports = router;
</code></pre>
<p>The most important line is the JWT payload:</p>
<pre><code class="language-javascript">jwt.sign({ id, email, role }, process.env.JWT_SECRET, { expiresIn: '24h' })
</code></pre>
<p>By embedding <code>role</code> in the token, every subsequent request carries the user's permissions without requiring a database lookup. The server just verifies the token and reads the role from the payload.</p>
<h2 id="heading-building-the-rbac-middleware">Building the RBAC Middleware</h2>
<p>This is the core of the system. We need two separate middleware functions:</p>
<ol>
<li><p><code>verifyToken</code> confirms the JWT is valid and attaches the decoded payload to <code>req.user</code></p>
</li>
<li><p><code>checkRole</code> confirms the user has the required role for a specific route</p>
</li>
</ol>
<p>Keeping them separate gives you flexibility. Some routes only need authentication. Others need both authentication and a specific role.</p>
<p>Create <code>src/middleware/auth.js</code>:</p>
<pre><code class="language-javascript">const jwt = require('jsonwebtoken');

// Middleware 1: Verify the JWT token
const verifyToken = (req, res, next) =&gt; {
  const authHeader = req.headers['authorization'];
  const token = authHeader &amp;&amp; authHeader.split(' ')[1]; // Expects: Bearer &lt;token&gt;

  if (!token) {
    return res.status(401).json({ message: 'Access denied. No token provided.' });
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded; // Attach decoded payload (including role) to request
    next();
  } catch (err) {
    return res.status(403).json({ message: 'Invalid or expired token.' });
  }
};

// Middleware 2: Check if user has one of the required roles
const checkRole = (...allowedRoles) =&gt; {
  return (req, res, next) =&gt; {
    if (!req.user) {
      return res.status(401).json({ message: 'Not authenticated.' });
    }

    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({
        message: `Access denied. Required role: ${allowedRoles.join(' or ')}. Your role: ${req.user.role}`,
      });
    }

    next();
  };
};

module.exports = { verifyToken, checkRole };
</code></pre>
<p><code>checkRole</code> uses a rest parameter (<code>...allowedRoles</code>) so you can pass in one or multiple roles:</p>
<pre><code class="language-javascript">checkRole('admin')                  // only admin
checkRole('editor', 'admin')        // editor or admin
checkRole('user', 'editor', 'admin') // all roles
</code></pre>
<p>This makes route definitions clean and readable — the permissions are visible right at the route level.</p>
<h2 id="heading-building-the-protected-routes">Building the Protected Routes</h2>
<p>Now let's wire up routes that use the middleware.</p>
<p>Create <code>src/routes/content.js</code>:</p>
<pre><code class="language-javascript">const express = require('express');
const { verifyToken, checkRole } = require('../middleware/auth');

const router = express.Router();

// In-memory content store
const content = [
  { id: '1', title: 'Getting Started with Node.js', author: 'admin' },
  { id: '2', title: 'Express Middleware Explained', author: 'editor' },
];

// GET /api/content — all authenticated users
router.get('/', verifyToken, checkRole('user', 'editor', 'admin'), (req, res) =&gt; {
  res.json({ content });
});

// POST /api/content — editors and admins only
router.post('/', verifyToken, checkRole('editor', 'admin'), (req, res) =&gt; {
  const { title } = req.body;

  if (!title) {
    return res.status(400).json({ message: 'Title is required' });
  }

  const newItem = {
    id: Date.now().toString(),
    title,
    author: req.user.email,
  };

  content.push(newItem);
  res.status(201).json({ message: 'Content created', item: newItem });
});

// DELETE /api/content/:id — admin only
router.delete('/:id', verifyToken, checkRole('admin'), (req, res) =&gt; {
  const index = content.findIndex((c) =&gt; c.id === req.params.id);

  if (index === -1) {
    return res.status(404).json({ message: 'Content not found' });
  }

  content.splice(index, 1);
  res.json({ message: 'Content deleted successfully' });
});

module.exports = router;
</code></pre>
<p>Notice how readable each route is:</p>
<pre><code class="language-javascript">router.delete('/:id', verifyToken, checkRole('admin'), handler)
</code></pre>
<p>You can understand the access control without reading the handler body. This is one of the key advantages of middleware-based RBAC: permissions live at the routing layer, not buried in business logic.</p>
<p>Create <code>src/routes/admin.js</code>:</p>
<pre><code class="language-javascript">const express = require('express');
const { verifyToken, checkRole } = require('../middleware/auth');
const { getAllUsers } = require('../data/users');

const router = express.Router();

// GET /api/admin/users — admin only
router.get('/users', verifyToken, checkRole('admin'), (req, res) =&gt; {
  res.json({ users: getAllUsers() });
});

module.exports = router;
</code></pre>
<h2 id="heading-putting-it-all-together">Putting It All Together</h2>
<p>Create <code>src/app.js</code>:</p>
<pre><code class="language-javascript">require('dotenv').config();
const express = require('express');

const authRoutes = require('./routes/auth');
const contentRoutes = require('./routes/content');
const adminRoutes = require('./routes/admin');

const app = express();

app.use(express.json());

// Routes
app.use('/api/auth', authRoutes);
app.use('/api/content', contentRoutes);
app.use('/api/admin', adminRoutes);

// Health check
app.get('/', (req, res) =&gt; {
  res.json({ message: 'RBAC API is running' });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () =&gt; {
  console.log(`Server running on port ${PORT}`);
});
</code></pre>
<h2 id="heading-testing-the-api">Testing the API</h2>
<p>Start the server:</p>
<pre><code class="language-bash">npm run dev
</code></pre>
<h3 id="heading-step-1-register-users-with-different-roles">Step 1: Register Users with Different Roles</h3>
<p>Register an admin:</p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name": "Admin User", "email": "admin@example.com", "password": "password123", "role": "admin"}'
</code></pre>
<p>Register an editor:</p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name": "Editor User", "email": "editor@example.com", "password": "password123", "role": "editor"}'
</code></pre>
<p>Register a regular user (no role specified — defaults to <code>user</code>):</p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name": "Regular User", "email": "user@example.com", "password": "password123"}'
</code></pre>
<h3 id="heading-step-2-log-in-and-get-a-token">Step 2: Log in and Get a Token</h3>
<pre><code class="language-bash">curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "password": "password123"}'
</code></pre>
<p>You'll get a response like:</p>
<pre><code class="language-json">{
  "message": "Login successful",
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
</code></pre>
<p>Copy the token.</p>
<h3 id="heading-step-3-test-role-based-access">Step 3: Test Role-based Access</h3>
<p><strong>Read content as a regular user (should succeed):</strong></p>
<pre><code class="language-bash">curl http://localhost:3000/api/content \
  -H "Authorization: Bearer YOUR_TOKEN_HERE"
</code></pre>
<p><strong>Try creating content as a regular user (should fail — 403):</strong></p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/api/content \
  -H "Authorization: Bearer YOUR_TOKEN_HERE" \
  -H "Content-Type: application/json" \
  -d '{"title": "New Article"}'
</code></pre>
<p>Response:</p>
<pre><code class="language-json">{
  "message": "Access denied. Required role: editor or admin. Your role: user"
}
</code></pre>
<p>Now log in as an editor and try the same POST request. It succeeds. Log in as admin and try the DELETE route. Only the admin token will work.</p>
<h3 id="heading-step-4-decode-the-jwt-to-see-the-role">Step 4: Decode the JWT to See the Role</h3>
<p>You can paste any token into <a href="https://jwt.io">jwt.io</a> to inspect the payload. You'll see something like:</p>
<pre><code class="language-json">{
  "id": "1720300000000",
  "email": "admin@example.com",
  "role": "admin",
  "iat": 1720300000,
  "exp": 1720386400
}
</code></pre>
<p>The <code>role</code> field is exactly what <code>checkRole</code> reads on every protected request.</p>
<h2 id="heading-key-takeaways">Key Takeaways</h2>
<p>Roles live in the JWT payload. The role travels with the token — no extra DB call needed every time someone hits a protected route. It gets embedded at login and verified cryptographically on each request.</p>
<p>Middleware is composable. <code>verifyToken</code> and <code>checkRole</code> are separate, reusable functions. You can chain them on any route in any combination.</p>
<p>Permissions are visible at the route level. <code>router.delete('/:id', verifyToken, checkRole('admin'), handler)</code> tells you everything about access control before you even read the handler.</p>
<p><strong>Before you ship this to production:</strong></p>
<ul>
<li><p>The in-memory array was just to keep this tutorial focused — replace it with a real database before anything goes near production. A server restart wipes all your users right now.</p>
</li>
<li><p>That 24h token expiry is too long. Cut it to 15 minutes and add refresh token rotation. A stolen token becomes useless fast.</p>
</li>
<li><p>Re-validate roles from the DB on sensitive operations. A role change won't reflect in an existing token until it expires</p>
</li>
<li><p>HTTPS, always</p>
</li>
<li><p>If your permission logic grows beyond "check a role", look at <a href="https://casl.js.org/">casl</a>. It handles attribute-level rules cleanly</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The core of it fits in two middleware functions and a JWT payload. I've used this same pattern across several projects. And once you've built it yourself, you'll start spotting it everywhere, because almost every multi-user app needs some version of it.</p>
<p><strong>Full source code on GitHub:</strong> <a href="https://github.com/ziaongit/nodejs-rbac-jwt-api">github.com/ziaongit/nodejs-rbac-jwt-api</a></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Secure-by-Default Node.js APIs ]]>
                </title>
                <description>
                    <![CDATA[ Most security problems I've shipped in my career weren't exotic. They weren't nation-state attacks or clever zero-days. They were boring. A missing limit here, a forgotten timeout there, a string comp ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-secure-by-default-node-js-apis/</link>
                <guid isPermaLink="false">6a3c3fc702ebd10f875ab988</guid>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Devlin Duldulao ]]>
                </dc:creator>
                <pubDate>Wed, 24 Jun 2026 20:36:23 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/edebad91-82e3-4d67-b136-bbb99859a393.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most security problems I've shipped in my career weren't exotic. They weren't nation-state attacks or clever zero-days. They were boring. A missing limit here, a forgotten timeout there, a string comparison that leaked a secret one millisecond at a time.</p>
<p>The boring stuff is what gets you, because the boring stuff is what everyone agrees to fix "later," and later has a way of never arriving.</p>
<p>My favorite personal example (favorite in the way a scar is your favorite) was an internal API that compared an access token with a plain equality check and had no limit on request size. It ran fine for a year. It ran fine right up until someone curious discovered they could both fingerprint the token comparison and post a body large enough to make the server sweat.</p>
<p>Neither bug was sophisticated. Both would have been a complete non-event if something had simply refused to let me do the wrong thing in the first place.</p>
<p>This tutorial shows you how to add practical guardrails around every HTTP API you build, regardless of framework. You'll write them by hand, in plain Node.js, with no dependencies. This will let you see exactly what each one does and why.</p>
<p>By the end, you'll have a small server that survives a lot more contact with the public internet than the version most of us shipped early in our careers.</p>
<p>This tutorial uses plain JavaScript so anyone can copy and run it. If you use TypeScript, you can add types afterward. You need Node 22 or newer, a basic understanding of HTTP requests and responses, and a terminal for testing the examples.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ol>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-how-to-start-with-the-naive-server">How to Start with the Naïve Server</a></p>
</li>
<li><p><a href="#heading-how-to-limit-the-request-body">How to Limit the Request Body</a></p>
</li>
<li><p><a href="#heading-how-to-time-out-slow-requests">How to Time Out Slow Requests</a></p>
</li>
<li><p><a href="#heading-how-to-parse-json-safely-and-block-prototype-pollution">How to Parse JSON Safely and Block Prototype Pollution</a></p>
</li>
<li><p><a href="#heading-how-to-set-security-headers-on-every-response">How to Set Security Headers on Every Response</a></p>
</li>
<li><p><a href="#heading-how-to-compare-secrets-in-constant-time">How to Compare Secrets in Constant Time</a></p>
</li>
<li><p><a href="#heading-how-to-validate-input-as-a-gate-not-a-suggestion">How to Validate Input as a Gate, Not a Suggestion</a></p>
</li>
<li><p><a href="#heading-how-to-fail-without-leaking-and-log-so-you-can-see-it">How to Fail Without Leaking and Log So You Can See It</a></p>
</li>
<li><p><a href="#heading-how-to-put-it-all-together">How to Put It All Together</a></p>
</li>
<li><p><a href="#heading-how-to-handle-cors-correctly">How to Handle CORS Correctly</a></p>
</li>
<li><p><a href="#heading-what-this-tutorial-doesnt-cover">What This Tutorial Doesn't Cover</a></p>
</li>
<li><p><a href="#heading-why-defaults-beat-checklists">Why Defaults Beat Checklists</a></p>
</li>
<li><p><a href="#heading-an-honest-note-on-frameworks">An Honest Note on Frameworks</a></p>
</li>
<li><p><a href="#heading-the-takeaway-checklist">The Takeaway Checklist</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js 22 or newer</p>
</li>
<li><p>Basic familiarity with HTTP requests and responses</p>
</li>
<li><p>A terminal and curl, Postman, or a similar client</p>
</li>
</ul>
<p>This isn't a guide to making your API unhackable. It's a practical guide to avoiding the easy attacks and building safer defaults.</p>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>A plain Node.js API with request size limits, request timeouts, safe JSON parsing, security headers, timing-safe secret comparison, validation, and error handling.</p>
<h2 id="heading-how-to-start-with-the-naive-server">How to Start with the Naïve Server</h2>
<p>Here's the kind of server I wrote when I was younger and braver and wrong. It reads a JSON body and echoes it back. Pretend it's the start of a real API.</p>
<pre><code class="language-ts">import http from "node:http";

const server = http.createServer((req, res) =&gt; {
  let body = "";
  req.on("data", (chunk) =&gt; (body += chunk));
  req.on("end", () =&gt; {
    const data = JSON.parse(body || "{}");
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ youSent: data }));
  });
});

server.listen(3000, () =&gt; console.log("listening on http://localhost:3000"));
</code></pre>
<p>It works. You can curl it and it answers. It's also a small disaster, and here's the incomplete list of why:</p>
<ul>
<li><p>It reads an unbounded body into memory. Send it a few gigabytes and you have a denial-of-service with no effort.</p>
</li>
<li><p><code>JSON.parse</code> throws on bad input, which here means an uncaught exception that can take the process down.</p>
</li>
<li><p>There's no timeout. A client that sends one byte per minute can hold a connection hostage.</p>
</li>
<li><p>It sets no security headers and happily advertises that it's a Node server.</p>
</li>
<li><p>It parses untrusted JSON straight into an object with no checks, which opens the door to prototype pollution downstream.</p>
</li>
</ul>
<p>You're going to fix each of these issues. The fixes are small. The point is to make them habits, not heroics.</p>
<h2 id="heading-how-to-limit-the-request-body">How to Limit the Request Body</h2>
<p>The first rule of accepting input from strangers is to decide, in advance, how much input you're willing to accept. If you don't set a limit, the limit is "however much RAM the server has," and someone will find that out for you.</p>
<p>There are two layers here. The first is the <code>Content-Length</code> header, which the client sends to declare how big the body is. You can reject early based on it. But you must never trust it alone, because a client can lie or simply not send it.</p>
<p>The real defense is to count bytes as they stream in and stop the moment they cross your line.</p>
<pre><code class="language-ts">const MAX_BODY_BYTES = 100 * 1024; // 100 KB is plenty for most JSON APIs

function readBody(req, limit = MAX_BODY_BYTES) {
  return new Promise((resolve, reject) =&gt; {
    // Cheap early rejection if the client is honest about being too big.
    const declared = Number(req.headers["content-length"]);
    if (Number.isFinite(declared) &amp;&amp; declared &gt; limit) {
      reject(httpError(413, "Payload too large"));
      return;
    }

    let size = 0;
    const chunks = [];

    req.on("data", (chunk) =&gt; {
      size += chunk.length;
      if (size &gt; limit) {
        reject(httpError(413, "Payload too large"));
        req.destroy(); // stop reading; we are done with this client
        return;
      }
      chunks.push(chunk);
    });

    req.on("end", () =&gt; resolve(Buffer.concat(chunks)));
    req.on("error", reject);
  });
}

function httpError(statusCode, message) {
  return Object.assign(new Error(message), { statusCode });
}
</code></pre>
<p>A few things worth noticing here. You accumulate <code>Buffer</code> chunks and only join them at the end, rather than concatenating strings, because string concatenation forces an early decode and can mangle multibyte UTF-8 characters that happen to land on a chunk boundary.</p>
<p>You also call <code>req.destroy()</code> as soon as you cross the limit, so you don't keep pulling bytes you've already decided to refuse.</p>
<p>Pick a limit that matches the route. A JSON API that creates a user doesn't need a 50 MB body. A file upload endpoint is a different conversation, and there you would stream to disk or object storage instead of buffering in memory at all. The mistake is having no limit, not having the wrong one.</p>
<h2 id="heading-how-to-time-out-slow-requests">How to Time Out Slow Requests</h2>
<p>Once you have a body limit, the next trick an attacker reaches for is to be slow instead of large. This is the family of attacks named after slowloris, a sad-looking primate that moves very slowly, which is rude to the animal but accurate about the attack.</p>
<p>The idea is to open many connections and feed them bytes at a glacial pace, never finishing, so the server keeps each one alive waiting politely. Do that enough times and you've exhausted the connection pool without sending anything that looks malicious.</p>
<p>Node has built-in defenses for this, and the defaults are generous and worth tightening for an API.</p>
<pre><code class="language-ts">const server = http.createServer(handler);

// Total time allowed to receive the entire request (headers + body).
server.requestTimeout = 30_000; // 30 seconds

// Time allowed to receive just the headers. Slowloris lives here.
server.headersTimeout = 10_000; // 10 seconds

// Idle socket timeout: kill connections that go quiet.
server.setTimeout(60_000);
</code></pre>
<p>Those three lines handle the network layer. But there's a second kind of slow: your own handler. A database query that hangs, an outbound call to a third party that never answers, a regular expression that decided to think about its life choices. You want a ceiling on how long a single request is allowed to occupy a worker, and you want to be able to cancel the work when that ceiling is hit.</p>
<p>The modern tool for cancellation in Node is <code>AbortController</code>. Here's a small wrapper that gives every handler a deadline and a signal it can pass down to anything that supports cancellation, like <code>fetch</code>.</p>
<pre><code class="language-ts">function withTimeout(handler, ms = 15_000) {
  return async (req, res) =&gt; {
    const controller = new AbortController();
    const timer = setTimeout(() =&gt; controller.abort(), ms);
    try {
      await handler(req, res, controller.signal);
    } finally {
      clearTimeout(timer);
    }
  };
}
</code></pre>
<p>Now a handler can do <code>await fetch(url, { signal })</code> and the request gets cut off if it blows the deadline, instead of camping on a worker forever.</p>
<p>The discipline to learn here is that any time you talk to something outside your process, you give it a deadline. Networks fail in the most boring way possible: by hanging, not by erroring. A timeout turns a hang into a clean error you can handle.</p>
<h2 id="heading-how-to-parse-json-safely-and-block-prototype-pollution">How to Parse JSON Safely and Block Prototype Pollution</h2>
<p>This is the one people skip because it sounds theoretical, and then it shows up in a CVE with their stack in it.</p>
<p>First, the easy half. <code>JSON.parse</code> throws a <code>SyntaxError</code> on malformed input. In the naïve server, that throw was uncaught and could crash the process. So we wrap parsing and turn a parse failure into a clean 400.</p>
<pre><code class="language-ts">function parseJson(buffer) {
  if (buffer.length === 0) return {};
  let text = buffer.toString("utf8");
  try {
    return JSON.parse(text, reviver);
  } catch {
    throw httpError(400, "Invalid JSON body");
  }
}
</code></pre>
<p>Now the interesting half: that <code>reviver</code> argument. Prototype pollution is an attack where a request payload reaches up and modifies <code>Object.prototype</code>, the object that almost every object in your program inherits from. If an attacker can set a property there, they can set it on effectively everything at once.</p>
<p>It's easier to believe once you see it. Here's a recursive merge function, the kind people write all the time to apply updates onto an existing record:</p>
<pre><code class="language-ts">function merge(target, source) {
  for (const key in source) {
    if (source[key] &amp;&amp; typeof source[key] === "object") {
      if (!target[key]) target[key] = {};
      merge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
}
</code></pre>
<p>Looks harmless. Now feed it a payload an attacker controls:</p>
<pre><code class="language-ts">const evil = JSON.parse('{"__proto__": {"isAdmin": true}}');
const account = {};
merge(account, evil);

console.log(account.isAdmin);   // undefined, account itself is fine
console.log(({}).isAdmin);      // true  &lt;-- every object is now "admin"
</code></pre>
<p>That second line is the horror. You never touched <code>({})</code>. You polluted the shared prototype, so a brand new empty object now reports <code>isAdmin: true</code>. If somewhere later your code does <code>if (user.isAdmin)</code> on an object that didn't explicitly set that field, congratulations! Everyone is an admin. The <code>__proto__</code> key tricked the merge into walking up into the prototype that all objects share.</p>
<p>The defense is to refuse the dangerous keys before they ever get into your data. The cleanest way at parse time is the reviver, a function <code>JSON.parse</code> calls for every key as it builds the result. Return <code>undefined</code> for a key and it gets dropped.</p>
<pre><code class="language-ts">const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);

function reviver(key, value) {
  if (FORBIDDEN_KEYS.has(key)) return undefined;
  return value;
}
</code></pre>
<p>That is it. Three key names, blocked at the door, and the merge above becomes harmless because the payload never carries <code>__proto__</code> past the parser.</p>
<p>For extra defense in depth, you can also build internal lookup objects with <code>Object.create(null)</code>, which creates an object with no prototype at all, or use a <code>Map</code> when the keys are user-controlled. And if you want a belt to go with the suspenders, <code>Object.freeze(Object.prototype)</code> early in your process start will make the whole class of attack fail loudly.</p>
<p>I wouldn't rely on freezing alone, because some libraries get unhappy about it, but blocking the keys costs you nothing and should be the default.</p>
<h2 id="heading-how-to-set-security-headers-on-every-response">How to Set Security Headers on Every Response</h2>
<p>Browsers will defend your users for you, but only if you tell them to. That instruction comes as a small set of response headers. For an API that returns JSON, the list is short and the defaults are strict, which is exactly how you want it.</p>
<pre><code class="language-ts">function secureHeaders(res) {
  // Do not let the browser guess content types. Stops a JSON response
  // from being treated as HTML or a script.
  res.setHeader("X-Content-Type-Options", "nosniff");

  // Clickjacking defense: do not allow this response inside a frame.
  res.setHeader("X-Frame-Options", "DENY");
  res.setHeader("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'");

  // Do not leak the full URL (which may contain ids or tokens) on navigation.
  res.setHeader("Referrer-Policy", "no-referrer");

  // Only meaningful over HTTPS: force HTTPS for two years, including subdomains.
  res.setHeader("Strict-Transport-Security", "max-age=63072000; includeSubDomains");

  // Stop advertising what you are running. Free reconnaissance for nobody.
  res.removeHeader("X-Powered-By");
}
</code></pre>
<p>A quick tour, because cargo-culting headers is how you end up with a Content Security Policy that does nothing.</p>
<p><code>X-Content-Type-Options: nosniff</code> stops the browser from second-guessing your <code>Content-Type</code>, which closes a sneaky path where a response gets reinterpreted as something executable. <code>X-Frame-Options</code> and the <code>frame-ancestors</code> directive both refuse to let your responses be embedded in a frame, which is the heart of clickjacking. For a pure JSON API, <code>default-src 'none'</code> is a strong and appropriate CSP, because an API has no business loading scripts, styles, or images.</p>
<p><code>Referrer-Policy: no-referrer</code> keeps your URLs, which sometimes carry ids you would rather not gossip about, from being sent to other sites. <code>Strict-Transport-Security</code> only matters once you're on HTTPS, but once you are, it stops downgrade attacks by telling the browser to refuse plain HTTP.</p>
<p>And removing <code>X-Powered-By</code> is a tiny thing that just denies an attacker a free hint about what to throw at you.</p>
<p>The reason to wrap this in a function and call it on every response is that "every response" is the part humans forget. Make it one call you do at the top of the request, before you can get distracted.</p>
<h2 id="heading-how-to-compare-secrets-in-constant-time">How to Compare Secrets in Constant Time</h2>
<p>Here's a bug that looks completely fine and is completely broken:</p>
<pre><code class="language-ts">if (providedApiKey === expectedApiKey) {
  // grant access
}
</code></pre>
<p>The problem is that <code>===</code> on strings is allowed to be fast. It compares character by character and returns <code>false</code> the instant it finds a mismatch. That means a wrong guess that gets the first character right takes very slightly longer to reject than one that gets it wrong immediately.</p>
<p>That timing difference is tiny, but it's measurable over enough requests, and it lets an attacker recover a secret one character at a time. This is a real attack, it has a name (a timing attack), and the fix is built into Node.</p>
<p>You want a comparison whose running time doesn't depend on where the first difference is. Node gives you <code>crypto.timingSafeEqual</code> for exactly this. It has one sharp edge: it throws if the two buffers have different lengths, and length itself is a leak.</p>
<p>The clean way to handle both problems at once is to hash both inputs to a fixed size first, then compare the hashes.</p>
<pre><code class="language-ts">import { timingSafeEqual, createHash } from "node:crypto";

function safeCompare(a, b) {
  // Hashing normalizes length (so timingSafeEqual is happy) and hides
  // the length of the real secret from a timing observer.
  const ha = createHash("sha256").update(String(a)).digest();
  const hb = createHash("sha256").update(String(b)).digest();
  return timingSafeEqual(ha, hb);
}
</code></pre>
<p>Use this anywhere you compare a value a stranger supplied against a secret you hold: API keys, webhook signatures, password reset tokens, session identifiers. The rule of thumb is simple: if being wrong about the comparison would let someone in, don't use <code>===</code>.</p>
<p>One important caveat so you don't misuse this. For user passwords, don't store them and compare with this function. Passwords get hashed with a slow, purpose-built algorithm so that even if your database leaks, the hashes are expensive to crack.</p>
<p>Node ships <code>scrypt</code> for this in <code>node:crypto</code>, and <code>bcrypt</code> and <code>argon2</code> are popular libraries. The <code>safeCompare</code> above is for comparing high-entropy secrets like tokens and keys, not for human-chosen passwords.</p>
<h2 id="heading-how-to-validate-input-as-a-gate-not-a-suggestion">How to Validate Input as a Gate, Not a Suggestion</h2>
<p>Everything so far has been about surviving hostile input at the transport level. Validation is about refusing input that doesn't match the shape your code expects, before that input reaches your business logic.</p>
<p>A surprising amount of "weird production behavior" is just a handler that assumed a field was a string and got an array, or assumed a number and got the string "NaN".</p>
<p>You can hand-roll validation for a small API, and it's healthy to see what that looks like before you reach for a library:</p>
<pre><code class="language-ts">function expect(condition, message) {
  if (!condition) throw httpError(400, message);
}

function parseCreateUser(data) {
  expect(typeof data.email === "string" &amp;&amp; data.email.includes("@"), "email is required");
  expect(typeof data.password === "string", "password is required");
  expect(data.password.length &gt;= 12, "password must be at least 12 characters");
  // Return only the fields you actually want. Ignore everything else.
  return { email: data.email, password: data.password };
}
</code></pre>
<p>Notice the last line. You build a fresh object with only the fields you asked for, rather than passing <code>data</code> straight through. This quietly closes a mass-assignment hole, where a client sends <code>{"email": "...", "password": "...", "role": "admin"}</code> and a careless handler writes the whole object into the database, role included. If you only copy the fields you meant to accept, the extra ones never matter.</p>
<p>For anything beyond a few routes, a schema library pays for itself fast. Zod and Valibot are the popular choices, and both let you describe the shape once and get validation plus inferred types out of it.</p>
<pre><code class="language-ts">import { z } from "zod";

const CreateUser = z
  .object({
    email: z.string().email(),
    password: z.string().min(12),
  })
  .strict(); // reject unknown keys instead of ignoring them

const result = CreateUser.safeParse(data);
if (!result.success) throw httpError(400, "Validation failed");
const user = result.data;
</code></pre>
<p>That <code>.strict()</code> call is doing the same mass-assignment defense as our hand-rolled version, but declaratively. Whether you hand-roll it or use a library, the principle is the same: input is guilty until proven to match a shape you defined on purpose.</p>
<h2 id="heading-how-to-fail-without-leaking-and-log-so-you-can-see-it">How to Fail Without Leaking and Log So You Can See It</h2>
<p>Errors are going to happen. The real question is what your server says when they do, and whether you can reconstruct what went wrong afterward.</p>
<p>There are two common ways to get this wrong, and they are opposites. Either you hand attackers a map of your internals, or you blind yourself during an incident.</p>
<p>The leaking version looks like this, and yes, I've shipped it:</p>
<pre><code class="language-ts">catch (err) {
  res.writeHead(500);
  res.end(err.stack); // please do not
}
</code></pre>
<p>That stack trace can include file paths, library versions, query fragments, and sometimes secrets that got interpolated into an error message. It's a free briefing for whoever is poking at you.</p>
<p>The rule is blunt: a 500 should tell the client nothing useful, and tell you everything, through your logs.</p>
<p>The opposite failure is hiding the error so thoroughly that when it happens in production at 2am you have nothing to go on.</p>
<p>The fix for both problems is the same small idea: a request id. It's a short unique value you attach to each request, return to the client in a header, and include in every log line for that request. When a user reports "I got an error and it said request abc123," you can find exactly that request in your logs in seconds.</p>
<pre><code class="language-ts">import { randomUUID } from "node:crypto";

function withRequestId(req, res) {
  const requestId = req.headers["x-request-id"] ?? randomUUID();
  res.setHeader("X-Request-Id", requestId);
  return requestId;
}

function log(level, requestId, message, extra = {}) {
  // Structured logs: one JSON object per line, easy to search and ship.
  console.log(
    JSON.stringify({ level, requestId, message, ...extra, at: new Date().toISOString() }),
  );
}

function sendError(res, err, requestId) {
  const status = err.statusCode ?? 500;
  const message = status === 500 ? "Internal Server Error" : err.message;
  if (status === 500) {
    log("error", requestId, "unhandled error", { stack: err.stack });
  }
  if (!res.headersSent) {
    res.writeHead(status, { "Content-Type": "application/json" });
  }
  res.end(JSON.stringify({ error: message, requestId }));
}
</code></pre>
<p>The client gets the <code>requestId</code> but never the details. They can quote it to support, but they can't read your stack trace. Accept an incoming <code>X-Request-Id</code> when a trusted upstream set one, so a single request keeps the same id as it moves across your services, but generate your own whenever it is missing. Structured logs, one JSON object per line, are worth the slight ugliness, because they're trivial to filter and feed into a log aggregator, which a pile of freeform <code>console.log</code> calls is not.</p>
<p>One note on environments. It's fine, even helpful, to return richer error detail when you run locally. Just gate it on an explicit environment check and make production the strict default, so the worst outcome of a misconfiguration is too little information leaked, never too much.</p>
<h2 id="heading-how-to-put-it-all-together">How to Put It All Together</h2>
<p>None of these guardrails is impressive on its own. The power is in having all of them on, by default, on every route, so that the safe path is the path of least resistance.</p>
<p>Here is the naïve server from the start, rebuilt with everything we covered. It's still tiny. It's just no longer naïve.</p>
<pre><code class="language-ts">import http from "node:http";
import { timingSafeEqual, createHash } from "node:crypto";

const MAX_BODY_BYTES = 100 * 1024;
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);

function httpError(statusCode, message) {
  return Object.assign(new Error(message), { statusCode });
}

function reviver(key, value) {
  return FORBIDDEN_KEYS.has(key) ? undefined : value;
}

function readBody(req, limit = MAX_BODY_BYTES) {
  return new Promise((resolve, reject) =&gt; {
    const declared = Number(req.headers["content-length"]);
    if (Number.isFinite(declared) &amp;&amp; declared &gt; limit) {
      return reject(httpError(413, "Payload too large"));
    }
    let size = 0;
    const chunks = [];
    req.on("data", (chunk) =&gt; {
      size += chunk.length;
      if (size &gt; limit) {
        reject(httpError(413, "Payload too large"));
        req.destroy();
        return;
      }
      chunks.push(chunk);
    });
    req.on("end", () =&gt; resolve(Buffer.concat(chunks)));
    req.on("error", reject);
  });
}

function parseJson(buffer) {
  if (buffer.length === 0) return {};
  try {
    return JSON.parse(buffer.toString("utf8"), reviver);
  } catch {
    throw httpError(400, "Invalid JSON body");
  }
}

function secureHeaders(res) {
  res.setHeader("X-Content-Type-Options", "nosniff");
  res.setHeader("X-Frame-Options", "DENY");
  res.setHeader("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'");
  res.setHeader("Referrer-Policy", "no-referrer");
  res.setHeader("Strict-Transport-Security", "max-age=63072000; includeSubDomains");
  res.removeHeader("X-Powered-By");
}

function safeCompare(a, b) {
  const ha = createHash("sha256").update(String(a)).digest();
  const hb = createHash("sha256").update(String(b)).digest();
  return timingSafeEqual(ha, hb);
}

function sendJson(res, status, payload) {
  if (!res.headersSent) {
    res.writeHead(status, { "Content-Type": "application/json" });
  }
  res.end(JSON.stringify(payload));
}

function sendError(res, err) {
  const status = err.statusCode ?? 500;
  // Never leak internal error details on a 500. Log them, do not ship them.
  const message = status === 500 ? "Internal Server Error" : err.message;
  if (status === 500) console.error(err);
  sendJson(res, status, { error: message });
}

const API_KEY = process.env.API_KEY ?? "dev-only-key";

async function handler(req, res) {
  secureHeaders(res);

  // A single protected route, as an example.
  if (req.method === "POST" &amp;&amp; req.url === "/users") {
    const provided = req.headers["x-api-key"] ?? "";
    if (!safeCompare(provided, API_KEY)) {
      throw httpError(401, "Unauthorized");
    }

    const data = parseJson(await readBody(req));

    if (typeof data.email !== "string" || !data.email.includes("@")) {
      throw httpError(400, "email is required");
    }
    if (typeof data.password !== "string" || data.password.length &lt; 12) {
      throw httpError(400, "password must be at least 12 characters");
    }

    // Only the fields we asked for. No mass assignment.
    const user = { email: data.email };
    return sendJson(res, 201, { created: user });
  }

  throw httpError(404, "Not found");
}

const server = http.createServer((req, res) =&gt; {
  handler(req, res).catch((err) =&gt; sendError(res, err));
});

server.requestTimeout = 30_000;
server.headersTimeout = 10_000;
server.setTimeout(60_000);

server.listen(3000, () =&gt; console.log("listening on http://localhost:3000"));
</code></pre>
<p>Read that top to bottom and notice how the security isn't a separate "security middleware" bolted on at the end. It's woven into the normal flow.</p>
<p>The body has a limit. The JSON is parsed safely. The headers go out every time. The API key check is timing-safe. The validation runs before any logic. The error handler refuses to leak internals. And the whole thing is still small enough to hold in your head, which matters, because security you can't understand is security you'll eventually disable by accident.</p>
<p>Try breaking it. Send a huge body and watch the 413. Send <code>{"__proto__": {"isAdmin": true}}</code> and confirm <code>({}).isAdmin</code> is still <code>undefined</code> afterward. Send a wrong API key and notice you can't tell from the response time how close you were. That last one is invisible by design, which is the whole point.</p>
<h2 id="heading-how-to-handle-cors-correctly">How to Handle CORS Correctly</h2>
<p>CORS, which stands for Cross-Origin Resource Sharing, is one of the most misunderstood security features in web development, and the misunderstanding is the dangerous kind.</p>
<p>Here's the part people get wrong: CORS doesn't protect your server. It's not a firewall. It's a browser feature that decides whether JavaScript running on one website is allowed to read the response from your API on another. Your server stays perfectly reachable from curl, from Postman, and from any other server, CORS headers or not.</p>
<p>What that means in practice is that the most common "fix" people apply is also the most common mistake:</p>
<pre><code class="language-ts">res.setHeader("Access-Control-Allow-Origin", "*"); // understand this before you ship it
</code></pre>
<p>A wildcard says "any website's JavaScript may read my responses." For a genuinely public, read-only API with no credentials, that can be perfectly fine. For anything that uses cookies or returns data tied to a logged-in user, it's a mistake, and browsers will refuse to combine <code>*</code> with credentials anyway.</p>
<p>The correct approach is to allow only the origins you actually trust:</p>
<pre><code class="language-ts">const ALLOWED_ORIGINS = new Set(["https://app.example.com"]);

function applyCors(req, res) {
  const origin = req.headers.origin;
  if (origin &amp;&amp; ALLOWED_ORIGINS.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin"); // so a cache does not mix origins up
    res.setHeader("Access-Control-Allow-Credentials", "true");
  }
}
</code></pre>
<p>Keep this mental model: CORS loosens the browser's default protection in a controlled way. Setting it to <code>*</code> doesn't make your API more exposed to attacks from other servers, because servers were never restricted in the first place. It makes your data readable by any web page a victim happens to visit, which is a privacy and data-exposure decision, not a "make the console error go away" decision. Decide it on purpose, origin by origin.</p>
<h2 id="heading-what-this-tutorial-doesnt-cover">What This Tutorial Doesn't Cover</h2>
<p>Honesty time, because a tutorial that pretends to be totally complete is doing you a disservice. The guardrails above are the baseline, not the finish line.</p>
<p>Here's what's deliberately out of scope and where to look next:</p>
<ul>
<li><p><strong>Authentication and authorization:</strong> You checked one API key. Real apps need sessions or tokens, and a real story for who is allowed to do what. That is a whole topic on its own.</p>
</li>
<li><p><strong>Rate limiting:</strong> A single client shouldn't be able to hammer your login route ten thousand times a minute. In-memory counters work for one instance. Behind a load balancer you need a shared store like Redis.</p>
</li>
<li><p><strong>Outbound request safety (SSRF):</strong> The moment your server makes requests to URLs a user supplied, you have a new attack surface: someone can point you at internal addresses or the cloud metadata endpoint. That deserves its own article.</p>
</li>
<li><p><strong>TLS:</strong> Everything HSTS-related assumes you actually terminate HTTPS somewhere, whether that's the runtime, a reverse proxy, or your platform.</p>
</li>
<li><p><strong>Logging and monitoring:</strong> You can't respond to what you can't see. Structured logs with request ids are the unglamorous foundation of every incident response that went well.</p>
</li>
</ul>
<p>Each of these is a future tutorial, and each one follows the same philosophy as this one: make the safe choice the default, and make the unsafe choice something you have to go out of your way to do.</p>
<h2 id="heading-why-defaults-beat-checklists">Why Defaults Beat Checklists</h2>
<p>You might be wondering why I keep saying "by default" instead of just handing you a checklist and wishing you luck. The reason is that I've watched a lot of checklists lose to a deadline.</p>
<p>A checklist is a list of things a human has to remember to do, correctly, every single time, forever. That includes the junior dev who joined last week, and the senior dev who's exhausted and shipping a hotfix at midnight.</p>
<p>Security that depends on perfect human memory is security that quietly degrades the moment the team gets busy, which is precisely the moment an attacker is hoping for.</p>
<p>A default is a different kind of thing. A default is what happens when nobody does anything at all. If the safe behavior is the default, then forgetting produces a safe app. If the unsafe behavior is the default, then forgetting produces a vulnerability, and people forget constantly, because they're human and they have forty other things on their plate.</p>
<p>This is exactly why the helpers in this article are wrappers you call once at the top of a request, instead of steps you sprinkle through your handlers and hope you got them all. It's the same reason frameworks that take security seriously turn protections on and make you opt out, rather than leaving them off and making you opt in.</p>
<p>The wording sounds like a small difference. Measured across a real team over a real year, the difference in outcomes is enormous. Design it so the lazy path and the safe path are the same path, and you'll be surprised how secure "lazy" can be.</p>
<h2 id="heading-an-honest-note-on-frameworks">An Honest Note on Frameworks</h2>
<p>If wiring all of this by hand every time sounds tedious, that's exactly the right instinct, and it's why some frameworks ship these protections on by default so you don't have to remember them.</p>
<p>Full disclosure: I maintain one of them, an open-source project called DaloyJS. I'm not here to sell it, and everything in this article is plain Node that works the same no matter what you build on.</p>
<p>I mention it only because the lesson that produced it is the lesson of this whole piece: the defaults are the product. A framework that makes you opt into safety will, statistically, be run by someone who forgot to.</p>
<p>Whether you use a framework or roll your own, copy the helpers above into your project today. They're dependency-free and they will quietly prevent a category of bad days.</p>
<h2 id="heading-the-takeaway-checklist">The Takeaway Checklist</h2>
<p>If you remember nothing else, remember this list and put it somewhere your team will see it:</p>
<ul>
<li><p><strong>Limit the body:</strong> Count bytes as they stream, reject past your cap, never trust <code>Content-Length</code> alone.</p>
</li>
<li><p><strong>Time out everything:</strong> Tighten Node's request and header timeouts, and give every outbound call a deadline with <code>AbortController</code>.</p>
</li>
<li><p><strong>Parse JSON defensively:</strong> Catch parse errors into a clean 400, and strip <code>__proto__</code>, <code>constructor</code>, and <code>prototype</code> with a reviver.</p>
</li>
<li><p><strong>Set security headers on every response:</strong> Wrap them in one function so "every response" actually means every response.</p>
</li>
<li><p><strong>Compare secrets in constant time:</strong> Use <code>crypto.timingSafeEqual</code> on hashed inputs, never <code>===</code>, and use a real password hash for passwords.</p>
</li>
<li><p><strong>Validate input as a gate:</strong> Define the shape on purpose, reject what doesn't match, and copy only the fields you asked for.</p>
</li>
</ul>
<p>None of this is clever. That's the best thing about it. The boring stuff is what gets you, so make the boring stuff automatic, and go spend your cleverness on the parts of your product that actually need it.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Offline AI Image Generator in Node.js with QVAC and Socket.io ]]>
                </title>
                <description>
                    <![CDATA[ A few years ago, the first day I finally got access to an AI image generator, I was so excited that I immediately sat down and wrote an article about it (using Node.js and OpenAI's DALL-E). The magic  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-offline-ai-image-generator-in-node-js-with-qvac-and-socket-io/</link>
                <guid isPermaLink="false">6a3417433a2cf3cf64bb2225</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ image generation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ stable diffusion ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Express ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Jibril-M🍀 ]]>
                </dc:creator>
                <pubDate>Thu, 18 Jun 2026 16:05:23 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/db3fe63d-6df2-4250-b8f2-c166d9eafc3b.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A few years ago, the first day I finally got access to an AI image generator, I was so excited that I immediately sat down and wrote an article about it <a href="https://dev.to/djibrilm/dall-e-with-nodejs-5chb">(using Node.js and OpenAI's DALL-E)</a>. The magic of turning thoughts directly into digital pixels felt like holding a real-life magic wand.</p>
<p>But back then, accessing these models wasn't a walk in the park. Our primary option was Midjourney, which meant you had to struggle on Discord, and sometimes you couldn't do anything due to rate limits and servers being very busy.</p>
<p>Accessing image generation back then felt like trying to order a coffee during a flash mob.</p>
<p>Thankfully, the landscape has completely shifted. Today, not only can we run state-of-the-art models like Stable Diffusion on consumer hardware, but we can do it locally, offline, and completely free of charge. We don't need any API keys, there aren't any subscription rate limits, and there's no Discord channels to deal with.</p>
<p>In this tutorial, we'll build a local web application using Node.js, Express, Socket.io, and the QVAC SDK to run a quantized Stable Diffusion 2.1 model.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-is-qvac">What is QVAC?</a></p>
</li>
<li><p><a href="#heading-how-stable-diffusion-works-under-the-hood">How Stable Diffusion Works Under the Hood</a></p>
</li>
<li><p><a href="#heading-gpu-limitations">GPU Limitations: Metal, AMD, and the Intel Mac Trap</a></p>
</li>
<li><p><a href="#heading-the-image-generation-pipeline">The Image Generation Pipeline</a></p>
</li>
<li><p><a href="#heading-complete-implementation">Complete Implementation</a></p>
</li>
<li><p><a href="#heading-codebase-breakdown">Codebase Breakdown</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-resources-and-further-reading">Resources and Further Reading</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To get the most out of this tutorial, you should have a solid foundation in web backend and frontend basics:</p>
<ul>
<li><p><strong>Node.js and ES Modules</strong>: Basic familiarity with modern JavaScript modules (<code>import</code>/<code>export</code>), async loops, and event listeners.</p>
</li>
<li><p><strong>Express and WebSockets</strong>: Familiarity with routing static files and sending real-time messages over WebSockets with <code>socket.io</code>.</p>
</li>
<li><p><strong>HTML and Vanilla CSS</strong>: Understanding of basic DOM manipulation and style bindings.</p>
</li>
<li><p><strong>Development environment</strong>: A local machine with Node.js installed.</p>
</li>
</ul>
<h2 id="heading-what-is-qvac">What is QVAC?</h2>
<p>Developed by Tether, QVAC is a family of local inference tools designed to execute machine learning models directly on client hardware.</p>
<p>Instead of routing inference requests to expensive cloud-hosted APIs (such as DALL-E or Midjourney), QVAC bundles pre-compiled machine learning runtimes (like <code>llama.cpp</code> for text, <code>whisper.cpp</code> for transcription, and custom diffusion backends) directly into Node.js, mobile, and desktop runtimes.</p>
<p>Running local AI models with QVAC offers several practical advantages:</p>
<ul>
<li><p><strong>Zero API costs</strong>: Generate as many images as your hardware can handle without recurring costs.</p>
</li>
<li><p><strong>Privacy-first</strong>: Prompts and generated images are kept entirely in memory on your local machine.</p>
</li>
<li><p><strong>Offline independence</strong>: Run your application in isolated networks, on flights, or in regions without internet access.</p>
</li>
</ul>
<h2 id="heading-how-stable-diffusion-works-under-the-hood">How Stable Diffusion Works Under the Hood</h2>
<p>To execute image generation locally without running out of RAM, QVAC leverages a quantized <strong>Stable Diffusion 2.1 GGUF</strong> model (<code>SD_V2_1_1B_Q8_0</code>).</p>
<p>But how does this actual image generation process work conceptually? Let's make one thing clear: <strong>this is not a scientific paper</strong>. We aren't going to dive into the underlying multivariable calculus, probability distributions, or stochastic differential equations because I'm not a low-level machine learning researcher (and let's be honest, neither of us wants to stare at Greek symbols and linear algebra formulas on a screen when we could be writing clean JavaScript).</p>
<p>Instead, let's understand how these models work conceptually, using some intuitive developer analogies.</p>
<h3 id="heading-the-world-class-sculptor-analogy">The World-Class Sculptor Analogy</h3>
<p>At its core, modern AI image generation turns randomness into reality. Instead of "painting" an image from scratch, pixel-by-pixel, like a human illustrator with a brush, the AI essentially acts as a world-class sculptor, carving an image out of a block of digital static.</p>
<p>The most dominant technology behind this today is <strong>Diffusion</strong>, which powers models like Stable Diffusion, Midjourney, and Google's Imagen series.</p>
<p>Here is the conceptual step-by-step breakdown of how this block of static turns into art:</p>
<h4 id="heading-1-the-training-phase-learning-the-patterns">1. The Training Phase (Learning the Patterns)</h4>
<p>Before a model can generate anything, it has to look at billions of images and their corresponding text descriptions. During this phase, developers do something counterintuitive: <strong>they intentionally ruin the images</strong>.</p>
<ul>
<li><p><strong>Adding noise:</strong> The system takes a clear picture (for example, of a cat) and gradually adds random digital static (noise) pixel-by-pixel until the original image is completely unrecognizable.</p>
</li>
<li><p><strong>Learning to reverse it:</strong> The AI's job is to look at a noisy image and predict exactly how much noise was added at that specific step. By doing this billions of times, it becomes an expert at denoising – that is, turning chaos back into order.</p>
</li>
</ul>
<h4 id="heading-2-connecting-words-to-visuals-clip">2. Connecting Words to Visuals (CLIP)</h4>
<p>To make sure the AI knows what a <em>"cat wearing a top hat"</em> looks like, it uses a text-to-image bridge, often powered by a system called <strong>CLIP</strong> (Contrastive Language-Image Pre-training).</p>
<ul>
<li><p>CLIP translates human language into a mathematical map (called an <strong>embedding</strong>).</p>
</li>
<li><p>In this map, the words "cat" and the actual pixels of a cat sit very close together. This ensures that when you type a prompt, the AI knows exactly which visual concepts to pull from its memory.</p>
</li>
</ul>
<h4 id="heading-3-the-generation-phase-the-reverse-diffusion-loop">3. The Generation Phase (The Reverse Diffusion Loop)</h4>
<p>When you type a prompt and hit "Generate," the magic happens in reverse:</p>
<ul>
<li><p><strong>The blank canvas:</strong> The AI starts with a canvas of pure, 100% random digital noise (it looks like old television static).</p>
</li>
<li><p><strong>The prompt guidance:</strong> The AI looks at your prompt and uses its text embedding to guide its eye. It looks at the random static and asks, <em>"Where in this mess can I start to see a cat?"</em></p>
</li>
<li><p><strong>Step-by-step denoising:</strong> The AI subtracts a little bit of noise, sharpening the image slightly. It repeats this loop 20 to 50 times. With every step, fuzzy shapes turn into rough outlines, textures appear, and eventually, a crisp, clean, brand-new image emerges.</p>
</li>
</ul>
<p><strong>Fun fact about seeds:</strong> Because the process starts with completely random static every single time, typing the exact same prompt twice will always give you a completely different image (unless you lock down the starting randomness using a specific number called a <strong>Seed</strong>).</p>
<p>Here's an illustration of denoising with diffusion models:</p>
<img src="https://cdn.hashnode.com/uploads/covers/68e4f3e9867c1707d1b057a9/19131e8b-0f4f-4b76-a85b-2c7be2460a10.png" alt="Denoising with diffusion models diagram " style="display:block;margin:0 auto" width="3584" height="1234" loading="lazy">

<h3 id="heading-latent-diffusion-keeping-it-fast-the-vae">Latent Diffusion: Keeping it Fast (The VAE)</h3>
<p>Generating high-resolution images pixel-by-pixel requires massive computing power. If we tried to do this directly in pixel space on consumer hardware, our computers would melt, and a single generation would take hours.</p>
<p>To fix this, modern models use <strong>Latent Diffusion</strong>.</p>
<p>Instead of working with the full-sized image, a component called an <strong>encoder</strong> compresses the image into a smaller, abstract mathematical space (the <strong>"latent space"</strong>). Think of it as a shrunken playground where all the noisy/denoising math happens. Because this playground is so small, the computations are incredibly fast.</p>
<p>Once the denoising loop finishes in the latent space, another component called the <strong>decoder</strong> (specifically, a Variational Autoencoder, or VAE) blows it back up into a sharp, high-resolution image for you to see.</p>
<h3 id="heading-architectures-supported-by-qvac">Architectures Supported by QVAC</h3>
<p>When you run local inference with QVAC, the SDK hooks into optimized, community-maintained C++ backends. QVAC manages the hardware bindings and model lifecycles for different AI modalities:</p>
<ol>
<li><p><strong>Text generation (</strong><code>llama.cpp</code><strong>):</strong> Used for large language models (LLMs) like Llama 3 or Mistral, executing auto-regressive token prediction.</p>
</li>
<li><p><strong>Audio transcription (</strong><code>whisper.cpp</code><strong>):</strong> Used for highly optimized speech-to-text transcription.</p>
</li>
<li><p><strong>Image Generation (</strong><code>stable-diffusion.cpp</code> <strong>/</strong> <code>sdcpp-generation</code><strong>):</strong> Our focus in this tutorial. QVAC supports two distinct approaches for image generation depending on the model architecture you choose:</p>
<ul>
<li><p><strong>The Bundled Model Approach (Stable Diffusion 1.5/2.1/XL):</strong> The traditional approach where the entire pipeline (Text Encoders, VAE, and the main Diffusion UNet) is baked into a single, unified GGUF file (for example, <code>SD_V2_1_1B_Q8_0</code>).  </p>
<p>This is incredibly convenient for local deployments because you only need to manage and load one file to start generating images.</p>
</li>
<li><p><strong>The Modular Multi-Model Approach (Flux):</strong> Modern architectures like <strong>FLUX.1</strong> use a much more complex setup. Instead of a single file, Flux splits its computational brain into separate components. You load a core Diffusion Transformer (DiT) model, but you must also separately load large text encoders (like T5-v1.1-xxl and CLIP-L) and an independent VAE model.  </p>
<p>While this requires more complex orchestration to load multiple GGUF files simultaneously, it provides vastly superior prompt adherence and photorealism by utilizing dedicated, massive text-understanding models.</p>
</li>
</ul>
</li>
<li><p><strong>Speech synthesis (TTS):</strong> Specialized architectures like <em>Chatterbox</em> (transformer-based zero-shot voice cloning) and <em>Supertonic</em> (diffusion-based speech denoising).</p>
</li>
</ol>
<h2 id="heading-gpu-limitations-metal-amd-and-the-intel-mac-trap">GPU Limitations: Metal, AMD, and the Intel Mac Trap</h2>
<p>When running machine learning models locally on Apple Mac hardware, QVAC will try to automatically accelerate execution by compiling compute pipelines for the <strong>Metal</strong> API to utilize the system's GPU.</p>
<p>If you're on an Apple Silicon Mac (M1, M2, M3, M4, or M5 chip), this works seamlessly, and generation will compile on the Apple Neural Engine and Unified GPU memory in seconds.</p>
<p>But if you're running on an older Intel-based Mac with a discrete <strong>AMD Radeon GPU</strong> (such as the AMD Radeon Pro 5500M commonly found in 16-inch MacBook Pros), you'll run into a major driver-level limitation:</p>
<ul>
<li><p>The macOS Metal driver for older AMD discrete GPUs doesn't support the modern machine learning compute shaders and matrix reduction operators used by <code>stable-diffusion.cpp</code>.</p>
</li>
<li><p>When the inference worker attempts to run these unsupported operations, the driver fails to compile the pipeline and triggers a hard C++ crash (<code>SIGABRT</code>) inside the <code>ggml-metal-ops.cpp</code> shader encoder, abruptly exiting the background worker process.</p>
</li>
</ul>
<p>If you hit this hardware roadblock, the default GPU configuration will crash the application every time you trigger an image generation.</p>
<p>To resolve this, you should configure the model to run on the CPU instead by setting the model configuration parameter <code>device</code> to <code>"cpu"</code> and specifying the threads (for example, <code>threads: 4</code>). While generating images on the CPU takes longer than on a GPU, it runs successfully on any machine, regardless of how old or limited its GPU is.</p>
<h2 id="heading-the-image-generation-pipeline">The Image Generation Pipeline</h2>
<p>To coordinate the local execution lifecycle, our app sets up a real-time event pipeline:</p>
<pre><code class="language-plaintext">[Browser Client]                                  [Node.js Server]
       |                                                 |
       | ------ 1. Connects &amp; Checks Model ---------&gt;    |
       | &lt;----- 2. Downloads &amp; Loads Model ----------     | (Model Cached locally)
       |                                                 |
       | ------ 3. Submits prompt ("Cozy cabin...") -&gt;  |
       |                                                 |
       |                                                 | === [ QVAC Inference Engine ] ===
       |                                                 | 
       | &lt;----- 4. Denoising Step Updates (e.g. 5/20) -- | (Streams steps in real time)
       |                                                 |
       | &lt;----- 5. Sends final image (Base64 DataURL) -- | (Direct in-memory payload)
       |                                                 |
</code></pre>
<h2 id="heading-complete-implementation">Complete Implementation</h2>
<p>Let's look at the implementation. You can <a href="https://github.com/DjibrilM/qvac-local-image-generation-Case-study-">clone the full project repository</a> to follow along, or build it from scratch by creating a project folder, running <code>npm init -y</code>, installing the dependencies (<code>@qvac/sdk</code>, <code>express</code>, <code>socket.io</code>, <code>concurrently</code>), and configuring <code>"type": "module"</code> in your <code>package.json</code>.</p>
<h3 id="heading-1-server-configuration-serverjs">1. Server Configuration (<code>server.js</code>)</h3>
<p>Create a file named <code>server.js</code> and paste the following implementation:</p>
<pre><code class="language-javascript">import express from 'express';
import path from 'path';
import http from 'http';
import { Server } from 'socket.io';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { loadModel, unloadModel, getLoadedModelInfo, diffusion, SD_V2_1_1B_Q8_0 } from "@qvac/sdk";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const app = express();
const server = http.createServer(app);
const io = new Server(server);

const PORT = process.env.PORT || 3000;

app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));

const CONFIG_PATH = path.join(__dirname, '.device-preference.json');

function getPreferredDevice() {
  try {
    if (fs.existsSync(CONFIG_PATH)) {
      const data = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
      return data.device || null;
    }
  } catch (err) {
    console.error('Failed to read device preference:', err.message);
  }
  return null;
}

function setPreferredDevice(device) {
  try {
    fs.writeFileSync(CONFIG_PATH, JSON.stringify({ device }), 'utf8');
  } catch (err) {
    console.error('Failed to write device preference:', err.message);
  }
}

// Global model state
let loadedModelId = process.modelId || null;
let modelLoadPercent = 0;
let modelLoadStatus = 'Awaiting trigger...';
let isModelLoading = false;

const modelSize = (SD_V2_1_1B_Q8_0.expectedSize / (1024 * 1024 * 1024)).toFixed(2) + ' GB';

function broadcastModelProgress(percent, status) {
  io.emit('model-download-progress', { percent, status, size: modelSize });
}

io.on('connection', (socket) =&gt; {
  console.log('Client connected:', socket.id);

  socket.on('disconnect', () =&gt; {
    console.log('Client disconnected:', socket.id);
  });

  // Trigger model download
  socket.on('trigger-model-download', async () =&gt; {
    // If already loaded, verify it's still alive in the worker
    if (loadedModelId) {
      try {
        await getLoadedModelInfo({ modelId: loadedModelId });
        socket.emit('model-download-progress', {
          percent: 100,
          status: 'Model fully loaded locally.',
          size: modelSize
        });
        return;
      } catch (err) {
        console.log('Model ID was stale/not found, resetting state and reloading...', err.message);
        loadedModelId = null;
        process.modelId = null;
      }
    }

    // If currently loading, report current progress
    if (isModelLoading) {
      socket.emit('model-download-progress', {
        percent: Math.round(modelLoadPercent),
        status: modelLoadStatus,
        size: modelSize
      });
      return;
    }

    isModelLoading = true;
    modelLoadPercent = 0;
    modelLoadStatus = 'Initiating model download...';
    broadcastModelProgress(modelLoadPercent, modelLoadStatus);

    try {
      console.log('Starting model download...');
      const preferredDevice = getPreferredDevice();
      const loadConfig = { prediction: "v" };
      if (preferredDevice) {
        loadConfig.device = preferredDevice;
        if (preferredDevice === 'cpu') {
          loadConfig.threads = 4;
        }
        console.log(`Using cached device preference: ${preferredDevice}`);
      }

      loadedModelId = await loadModel({
        modelSrc: SD_V2_1_1B_Q8_0,
        modelType: "sdcpp-generation",
        modelConfig: loadConfig,
        onProgress: (p) =&gt; {
          modelLoadPercent = p.percentage;
          modelLoadStatus = p.percentage &gt;= 100 ? 'Model fully loaded locally.' : `Downloading model weights... (${p.percentage.toFixed(1)}%)`;
          broadcastModelProgress(Math.round(modelLoadPercent), modelLoadStatus);
        }
      });
      process.modelId = loadedModelId;

      isModelLoading = false;
      console.log('Model loaded successfully. ID:', loadedModelId);
    } catch (err) {
      isModelLoading = false;
      modelLoadPercent = 0;
      modelLoadStatus = 'Failed to load model: ' + err.message;
      console.error('Failed to load model:', err);
      broadcastModelProgress(0, modelLoadStatus);
      socket.emit('error_event', { message: 'Failed to load model: ' + err.message });
    }
  });

  socket.on('generate', async (data) =&gt; {
    const { prompt, ratio } = data;
    if (!prompt || prompt.trim() === '') {
      socket.emit('error_event', { message: 'Prompt is required' });
      return;
    }

    if (!loadedModelId) {
      socket.emit('error_event', { message: 'Model is not loaded yet' });
      return;
    }

    const runDiffusion = async (modelIdToUse) =&gt; {
      socket.emit('progress', {
        percent: 0,
        status: 'Starting diffusion process...',
        sub: 'DIFFUSION INITIALIZING'
      });

      console.log(`Generating image for prompt: "\({prompt}" with ratio: \){ratio} using model ID: ${modelIdToUse}`);

      const { progressStream, outputs, stats } = diffusion({
        modelId: modelIdToUse,
        prompt,
      });

      // Stream progress steps
      for await (const { step, totalSteps } of progressStream) {
        const percent = Math.round((step / totalSteps) * 100);
        socket.emit('progress', {
          percent,
          status: `Denoising step \({step}/\){totalSteps}...`,
          sub: 'RUNNING DIFFUSION'
        });
      }

      // Resolve output buffers
      const buffers = await outputs;
      if (!buffers || buffers.length === 0) {
        throw new Error('No image buffer returned from diffusion model.');
      }

      // Convert image buffer to a base64 Data URL instead of saving to disk
      const base64Data = Buffer.from(buffers[0]).toString('base64');
      const dataUrl = `data:image/png;base64,${base64Data}`;

      // Emit success
      socket.emit('success', {
        url: dataUrl,
        prompt,
        seed: (await stats).seed || -1
      });

      console.log(`Image generated and emitted successfully as base64 Data URL.`);
    };

    try {
      await runDiffusion(loadedModelId);
    } catch (err) {
      console.error('Image generation failed:', err);

      const isCrash = err.code === 50205 || (err.message &amp;&amp; err.message.includes('WORKER_CRASHED'));
      if (isCrash) {
        console.log('Worker crashed during GPU execution. Attempting CPU fallback...');

        // Save device preference so we load CPU directly next time and prevent double loading
        setPreferredDevice('cpu');

        // Reset the stale model state
        loadedModelId = null;
        process.modelId = null;

        socket.emit('progress', {
          percent: 0,
          status: 'GPU driver crashed. Automatically falling back to CPU mode...',
          sub: 'CPU FALLBACK LOADING'
        });

        try {
          console.log('Loading model on CPU...');
          isModelLoading = true;
          modelLoadPercent = 0;
          modelLoadStatus = 'Loading CPU model weights...';
          broadcastModelProgress(modelLoadPercent, modelLoadStatus);

          loadedModelId = await loadModel({
            modelSrc: SD_V2_1_1B_Q8_0,
            modelType: "sdcpp-generation",
            modelConfig: { prediction: "v", device: 'cpu', threads: 4 },
            onProgress: (p) =&gt; {
              modelLoadPercent = p.percentage;
              modelLoadStatus = `Loading CPU model weights... (${p.percentage.toFixed(1)}%)`;
              broadcastModelProgress(Math.round(modelLoadPercent), modelLoadStatus);
            }
          });
          process.modelId = loadedModelId;
          isModelLoading = false;
          console.log('Model loaded successfully on CPU. ID:', loadedModelId);

          // Retry diffusion on CPU
          await runDiffusion(loadedModelId);
        } catch (cpuErr) {
          console.error('CPU fallback execution failed:', cpuErr);
          isModelLoading = false;
          socket.emit('error_event', { message: 'Image generation failed on CPU: ' + cpuErr.message });
        }
      } else {
        if (err.message &amp;&amp; (err.message.includes('MODEL_NOT_FOUND') || err.message.includes('not found'))) {
          loadedModelId = null;
          process.modelId = null;
          broadcastModelProgress(0, 'Model state lost. Please re-trigger download.');
        }
        socket.emit('error_event', { message: 'Image generation failed: ' + err.message });
      }
    }
  });
});

app.get('*', (req, res) =&gt; {
  res.sendFile(path.join(__dirname, 'public', 'index.html'));
});

server.listen(PORT, () =&gt; {
  console.log(`Server is running at http://localhost:${PORT}`);
});

// Clean exit handler
async function handleCleanup() {
  const modelId = process.modelId || loadedModelId;
  if (modelId &amp;&amp; modelId !== 'mock-model-id') {
    try {
      await unloadModel({ modelId, clearStorage: false });
    } catch (err) {}
  }
  process.exit(0);
}

process.on('SIGINT', handleCleanup);
process.on('SIGTERM', handleCleanup);
</code></pre>
<h3 id="heading-2-frontend-architecture-summary">2. Frontend Architecture Summary</h3>
<p>Since our application runs completely locally, the frontend is a single-page web app built with vanilla HTML, CSS, and client-side JavaScript that communicates with our Express server over <strong>Socket.io</strong> WebSockets.</p>
<p>Rather than cluttering this tutorial with hundreds of lines of UI templates and style sheets, we'll keep the focus entirely on the backend orchestration. You can grab the complete HTML layout, Tailwind styles, and client script from the <a href="#heading-resources-and-further-reading">GitHub Repository</a>.</p>
<p>Here is a summary of how the client communicates with the server under the hood:</p>
<ol>
<li><p><strong>Preflight sync (</strong><code>trigger-model-download</code><strong>):</strong> As soon as the page loads, the client establishes a WebSocket connection and emits <code>trigger-model-download</code>. The server intercepts this to check if the model is cached/loading, and begins broadcasting progress.</p>
</li>
<li><p><strong>Denoising stream (</strong><code>progress</code><strong>):</strong> During image generation, the server constantly streams progress events containing denoising statistics (for example <code>Denoising step 12/20...</code>). The client updates the visual progress bar and status labels accordingly.</p>
</li>
<li><p><strong>Data URL delivery (</strong><code>success</code><strong>):</strong> When the diffusion steps are completed, the server converts the binary image buffer into a Base64 string and emits a <code>success</code> event. The client binds this Base64 Data URL directly to the source of the <code>&lt;img&gt;</code> element for direct local display and instant download.</p>
</li>
</ol>
<h2 id="heading-codebase-breakdown">Codebase Breakdown</h2>
<p>Let’s lift the hood on the key mechanisms that make our local offline image generator work smoothly.</p>
<h3 id="heading-1-multi-client-model-id-binding-processmodelid">1. Multi-Client Model ID Binding (<code>process.modelId</code>)</h3>
<p>Quantized weights take a significant amount of memory. Every time we call <code>loadModel()</code>, QVAC boots a separate C++ background process (a <code>Bare</code> worker) to host the GGML runtime.</p>
<p>To prevent spawning multiple processes or loading the 2.3 GB GGUF model multiple times when a client refreshes a page or opens another browser tab, we store the loaded model ID globally on Node’s <code>process</code> object:</p>
<pre><code class="language-javascript">let loadedModelId = process.modelId || null;
// ...
process.modelId = loadedModelId;
</code></pre>
<p>This acts as a process-wide singleton registry. But using a global variable introduces a challenge: <strong>stale worker processes</strong>. If a client triggers a model load, gets an ID, and the background worker process later crashes or is killed, <code>process.modelId</code> remains populated with a dead reference.</p>
<p>To resolve this, every time a new client connects and requests a model download trigger, we preflight the model ID using <code>getLoadedModelInfo</code>:</p>
<pre><code class="language-javascript">if (loadedModelId) {
  try {
    await getLoadedModelInfo({ modelId: loadedModelId });
    socket.emit('model-download-progress', { percent: 100, status: 'Model fully loaded locally.' });
    return;
  } catch (err) {
    console.log('Model ID was stale, resetting state...', err.message);
    loadedModelId = null;
    process.modelId = null;
  }
}
</code></pre>
<p>If the background worker is dead, <code>getLoadedModelInfo</code> throws an error. The catch block intercepts this, wipes the stale references, and safely restarts the loading routine.</p>
<p>[!IMPORTANT] <strong>Process singleton integrity:</strong> Always preflight model state visibility before initiating inference. Without validation checks, attempting <code>diffusion()</code> on a stale model ID will trigger immediate client-side connection timeouts and silent backend worker failures.</p>
<h3 id="heading-2-in-memory-image-serialization-zero-disk-writes">2. In-Memory Image Serialization (Zero Disk Writes)</h3>
<p>Writing generated images to the server's hard drive creates significant I/O overhead. It forces you to write custom cron cleanup scripts to delete old image files, and runs the risk of running out of disk space on systems with high user traffic.</p>
<p>Since QVAC’s <code>diffusion()</code> function outputs generated PNG files directly as in-memory binary buffers (<code>Uint8Array</code>), we bypass the local file system entirely. We serialize the binary array into a Base64 string directly in memory:</p>
<pre><code class="language-javascript">const base64Data = Buffer.from(buffers[0]).toString('base64');
const dataUrl = `data:image/png;base64,${base64Data}`;
</code></pre>
<p>This Data URL is transmitted over WebSockets to the client, which immediately binds it to the image element:</p>
<ul>
<li><p><strong>Zero disk overhead:</strong> The server doesn't write a single byte to the hard drive, preserving SSD life and preventing storage bloat.</p>
</li>
<li><p><strong>Instant delivery:</strong> Transmission is handled entirely within network memory buffers, bypassing disk serialization latency.</p>
</li>
<li><p><strong>Effortless client integration:</strong> The client doesn't need to request a static image URL path. It directly renders the Base64 Data URL, allowing users to save or download the image instantly.</p>
</li>
</ul>
<h3 id="heading-3-gpu-to-cpu-fallback-amp-preference-cache-strategy">3. GPU-to-CPU Fallback &amp; Preference Cache Strategy</h3>
<p>One of the biggest challenges with local-first AI is client hardware heterogeneity. For example, older Intel Macs with discrete AMD Radeon GPUs support Apple's Metal framework, but lack the modern tensor reduction operators used by the Stable Diffusion engine, causing a hard C++ crash (<code>SIGABRT</code>) inside <code>ggml-metal-ops.cpp</code>.</p>
<p>To keep the application running and ensure we don't trigger the model loading twice (once on the incompatible GPU on startup, and once on the CPU fallback after the first prompt crash), we use a persistent <strong>device preference cache</strong> file (<code>.device-preference.json</code>) alongside our C++ worker crash interceptor:</p>
<pre><code class="language-javascript">try {
  await runDiffusion(loadedModelId);
} catch (err) {
  const isCrash = err.code === 50205 || err.message.includes('WORKER_CRASHED');
  if (isCrash) {
    // 1. Cache the CPU preference on disk
    setPreferredDevice('cpu');

    // 2. Reset stale references
    loadedModelId = null;
    process.modelId = null;

    // 3. Automatically load the model on CPU with multi-threading
    loadedModelId = await loadModel({
      modelSrc: SD_V2_1_1B_Q8_0,
      modelType: "sdcpp-generation",
      modelConfig: { prediction: "v", device: "cpu", threads: 4 }
    });
    process.modelId = loadedModelId;

    // 4. Transparently retry generation
    await runDiffusion(loadedModelId);
  }
}
</code></pre>
<p>This approach utilizes a two-layered defense:</p>
<ol>
<li><p><strong>Dynamic recovery:</strong> If a GPU driver error triggers a crash, the app intercepts it, saves <code>"device": "cpu"</code> to the <code>.device-preference.json</code> file, dynamically reloads the weights into CPU threads, and retries the generation. The client simply sees a status update indicating CPU fallback is occurring, surviving what would otherwise be a fatal crash.</p>
</li>
<li><p><strong>Preference persistence:</strong> The next time the server starts or a page is loaded, the preflight loading routine reads the cached preference from the disk and loads the CPU model immediately:</p>
</li>
</ol>
<pre><code class="language-javascript">const preferredDevice = getPreferredDevice(); // Reads .device-preference.json
const loadConfig = { prediction: "v" };
if (preferredDevice) {
  loadConfig.device = preferredDevice;
  if (preferredDevice === 'cpu') {
    loadConfig.threads = 4;
  }
}
loadedModelId = await loadModel({
  modelSrc: SD_V2_1_1B_Q8_0,
  modelType: "sdcpp-generation",
  modelConfig: loadConfig,
  // ...
});
</code></pre>
<p>This prevents the server from making redundant GPU load attempts on subsequent sessions, ensuring that the model is loaded only once and directly onto the correct hardware execution target.</p>
<p>[!WARNING] <strong>CPU Fallback Latency:</strong> While CPU mode guarantees resilience across older hardware, it uses sequential multi-threaded calculations instead of GPU hardware cores. Consequently, generation times will be significantly longer (typically 1 to 2 minutes on CPU compared to 10 to 15 seconds on a compatible GPU). Make sure to design responsive progress loaders in the UI to manage user expectations during fallback.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Running local-first Stable Diffusion with QVAC gives you absolute control over your inference costs and data privacy. By coupling on-device GGML models with a simple Node.js WebSocket backend, you can build responsive web tools that run completely offline without ever spending money on cloud APIs.</p>
<p>As mobile and desktop system-on-chip architectures continue to pack more neural engines, local-first AI architectures will become an increasingly powerful option for modern developers.</p>
<h2 id="heading-resources-and-further-reading">Resources and Further Reading</h2>
<ul>
<li><p><a href="https://docs.qvac.tether.io/ai-capabilities/image-generation/#image-to-image"><strong>QVAC Image-to-Image Generation Documentation</strong></a></p>
</li>
<li><p><a href="https://github.com/tetherto/qvac-sdk"><strong>QVAC SDK GitHub Repository</strong></a></p>
</li>
<li><p><a href="https://huggingface.co/models?search=gguf"><strong>Stable Diffusion GGUF models on Hugging Face</strong></a></p>
</li>
<li><p><a href="https://socket.io/docs/v4/"><strong>Socket.io WebSockets Guide</strong></a></p>
</li>
<li><p><a href="https://github.com/DjibrilM/qvac-local-image-generation-Case-study-"><strong>Full codebase</strong></a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Complete SaaS Payment Flow with Stripe, Webhooks, and Email Notifications ]]>
                </title>
                <description>
                    <![CDATA[ Most Stripe tutorials end at the checkout page. The customer clicks "Pay," Stripe processes the charge, and the tutorial congratulates you on integrating payments. But that's only the first 10% of a r ]]>
                </description>
                <link>https://www.freecodecamp.org/news/saas-payment-flow-stripe-webhooks-email/</link>
                <guid isPermaLink="false">69fe0830f239332df4de5722</guid>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Software Engineering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Magnus Rødseth ]]>
                </dc:creator>
                <pubDate>Fri, 08 May 2026 15:58:40 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/de7d5c4d-062c-4879-892c-4486c7c461af.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most Stripe tutorials end at the checkout page. The customer clicks "Pay," Stripe processes the charge, and the tutorial congratulates you on integrating payments.</p>
<p>But that's only the first 10% of a real payment system.</p>
<p>What happens after the customer pays? You need to record the purchase in your database, send a confirmation email, and grant product access (a GitHub repo invitation, an API key, a license file). You need to notify yourself as the admin. You need to handle refunds two weeks later and send recovery emails when someone abandons checkout.</p>
<p>This is the complete payment lifecycle, and it's where most SaaS applications break.</p>
<p>This article walks you through building the entire flow, from the "Buy" button to the "Welcome" email and everything in between. Every code example comes from a production application processing real payments. You'll see how to design the database schema, create Stripe products, build the checkout flow, process purchases reliably, handle refunds, recover abandoned carts, and send transactional emails.</p>
<p>Here is what you'll learn:</p>
<ul>
<li><p>How to design a database schema that tracks every stage of a purchase</p>
</li>
<li><p>How to create Stripe products and prices programmatically</p>
</li>
<li><p>How to build a checkout flow with success/cancel handling</p>
</li>
<li><p>How to process webhooks securely with signature verification</p>
</li>
<li><p>How to split post-payment processing into durable, independently retried steps</p>
</li>
<li><p>How to handle full and partial refunds with automatic access revocation</p>
</li>
<li><p>How to recover revenue from abandoned checkouts</p>
</li>
<li><p>How to build transactional email templates with React Email and Resend</p>
</li>
<li><p>How to test the entire flow locally with Stripe CLI and Inngest</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-to-design-the-payment-database-schema">How to Design the Payment Database Schema</a></p>
</li>
<li><p><a href="#heading-how-to-create-stripe-products-and-prices">How to Create Stripe Products and Prices</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-checkout-flow">How to Build the Checkout Flow</a></p>
</li>
<li><p><a href="#heading-how-to-handle-webhooks-securely">How to Handle Webhooks Securely</a></p>
</li>
<li><p><a href="#heading-how-to-process-purchases-with-durable-background-jobs">How to Process Purchases with Durable Background Jobs</a></p>
</li>
<li><p><a href="#heading-how-to-handle-refunds">How to Handle Refunds</a></p>
</li>
<li><p><a href="#heading-how-to-recover-abandoned-checkouts">How to Recover Abandoned Checkouts</a></p>
</li>
<li><p><a href="#heading-how-to-send-transactional-emails-with-react-email">How to Send Transactional Emails with React Email</a></p>
</li>
<li><p><a href="#heading-how-to-test-the-complete-flow-locally">How to Test the Complete Flow Locally</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should be familiar with:</p>
<ul>
<li><p>TypeScript and Node.js</p>
</li>
<li><p>SQL databases (the examples use PostgreSQL)</p>
</li>
<li><p>React (for email templates)</p>
</li>
<li><p>Basic understanding of webhooks</p>
</li>
</ul>
<p>You don't need prior experience with any of the specific libraries. This handbook explains each one as it appears.</p>
<h3 id="heading-what-you-need-installed">What You Need Installed</h3>
<p>Install these packages to run the code examples:</p>
<pre><code class="language-bash">bun add stripe drizzle-orm @neondatabase/serverless inngest resend @react-email/components
</code></pre>
<p>You'll also need:</p>
<ul>
<li><p>A <a href="https://dashboard.stripe.com/register">Stripe account</a> (test mode is fine)</p>
</li>
<li><p>A <a href="https://neon.tech">Neon</a> PostgreSQL database (or any PostgreSQL instance)</p>
</li>
<li><p>A <a href="https://resend.com">Resend</a> account for sending emails</p>
</li>
<li><p>The <a href="https://stripe.com/docs/stripe-cli">Stripe CLI</a> for local webhook testing</p>
</li>
</ul>
<h3 id="heading-environment-variables">Environment Variables</h3>
<p>Set up these environment variables in your <code>.env</code> file:</p>
<pre><code class="language-bash"># Database
DATABASE_URL=postgresql://...

# Stripe
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRO_PRICE_ID=price_...

# Email
RESEND_API_KEY=re_...
EMAIL_FROM="Your App &lt;noreply@mail.yourapp.com&gt;"
ADMIN_EMAIL=you@yourapp.com

# App
BETTER_AUTH_URL=http://localhost:3000
</code></pre>
<h2 id="heading-how-to-design-the-payment-database-schema">How to Design the Payment Database Schema</h2>
<p>Before writing any Stripe code, you need a database schema that can track a purchase through every stage of its lifecycle: creation, completion, partial refund, and full refund.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a694d8d4dc9b42434c218f/6d0650fa-a568-4cb5-8560-8a2414635476.png" alt="Purchase status state machine showing transitions from pending to completed via Stripe webhook, then to refunded or partially refunded" style="display:block;margin:0 auto" width="5504" height="3072" loading="lazy">

<p>A purchase starts as <code>pending</code> when the user clicks "Buy." After Stripe confirms payment, it transitions to <code>completed</code>. From there, it can move to <code>refunded</code> or <code>partially_refunded</code>. Pending purchases that are never completed expire after 24 hours (abandoned carts).</p>
<p>Here is the schema I use in production, defined with <a href="https://orm.drizzle.team">Drizzle ORM</a>. The examples throughout this article grant access to a private GitHub repository because that's what this particular product sells.</p>
<p>Your "grant access" step will be different: upgrading a user to a Pro plan, provisioning API credits, unlocking course content, or activating a subscription. The schema fields and step logic change, but the durable execution pattern is the same.</p>
<pre><code class="language-typescript">// src/lib/db/schema.ts
import {
  boolean,
  integer,
  pgEnum,
  pgTable,
  text,
  timestamp,
  varchar,
} from "drizzle-orm/pg-core";

export const purchaseTierEnum = pgEnum("purchase_tier", ["pro"]);
export const purchaseStatusEnum = pgEnum("purchase_status", [
  "completed",
  "partially_refunded",
  "refunded",
]);

export const users = pgTable("users", {
  id: text("id").primaryKey(),
  email: varchar("email", { length: 255 }).notNull().unique(),
  emailVerified: boolean("email_verified").notNull().default(false),
  name: text("name"),
  image: text("image"),
  githubUsername: text("github_username"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const purchases = pgTable("purchases", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() =&gt; crypto.randomUUID()),
  userId: text("user_id")
    .notNull()
    .references(() =&gt; users.id, { onDelete: "cascade" }),
  stripeCheckoutSessionId: text("stripe_checkout_session_id")
    .notNull()
    .unique(),
  stripeCustomerId: text("stripe_customer_id"),
  stripePaymentIntentId: text("stripe_payment_intent_id"),
  tier: purchaseTierEnum("tier").notNull(),
  status: purchaseStatusEnum("status").notNull().default("completed"),
  githubAccessGranted: boolean("github_access_granted")
    .notNull()
    .default(false),
  githubInvitationId: text("github_invitation_id"),
  amount: integer("amount").notNull(),
  currency: text("currency").notNull().default("usd"),
  purchasedAt: timestamp("purchased_at").notNull().defaultNow(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export type Purchase = typeof purchases.$inferSelect;
export type NewPurchase = typeof purchases.$inferInsert;
</code></pre>
<p>Let me walk through the design decisions behind this schema.</p>
<h3 id="heading-why-three-stripe-id-columns">Why Three Stripe ID Columns?</h3>
<p>The <code>purchases</code> table stores three separate Stripe identifiers: <code>stripeCheckoutSessionId</code>, <code>stripeCustomerId</code>, and <code>stripePaymentIntentId</code>.</p>
<p>Each one serves a different purpose.</p>
<p>The <strong>checkout session ID</strong> is what you receive first. When a customer starts checkout, Stripe creates a session and gives you this ID. You use it to claim the purchase after the customer returns from Stripe's hosted checkout page.</p>
<p>The <code>unique()</code> constraint on this column is your idempotency guard. If someone tries to claim the same session twice, the database rejects the second insert.</p>
<p>The <strong>customer ID</strong> is Stripe's internal identifier for the buyer. You need this to look up the customer's payment history in Stripe's dashboard and to create future checkout sessions pre-filled with their billing info.</p>
<p>The <strong>payment intent ID</strong> is what Stripe sends in refund webhook events. When a <code>charge.refunded</code> event fires, it includes the payment intent ID but not the checkout session ID. Without storing this field, you would have no way to match a refund back to a purchase in your database.</p>
<h3 id="heading-why-track-access-state-in-your-database">Why Track Access State in Your Database</h3>
<p>The <code>githubAccessGranted</code> and <code>githubInvitationId</code> fields might look unnecessary. You could check GitHub's API to see if a user has access. But querying an external API every time you need to check a user's access state is slow, rate-limited, and unreliable.</p>
<p>By tracking access state in your own database, you can answer "does this user have access?" with a single indexed query. You also know whether access was ever granted, which is critical for refund processing. If <code>githubAccessGranted</code> is <code>false</code>, you don't need to revoke anything on refund.</p>
<h3 id="heading-why-a-status-enum-with-three-values">Why a Status Enum with Three Values?</h3>
<p>The <code>purchaseStatusEnum</code> has three values: <code>completed</code>, <code>partially_refunded</code>, and <code>refunded</code>.</p>
<p>This matters for downstream logic. Your dashboard, analytics, support tools, and email sequences all need to know the exact state of a purchase. A partially refunded customer still has access, but a fully refunded customer doesn't.</p>
<p>If you only tracked "refunded" as a boolean, you would lose the distinction between partial and full refunds. That distinction affects whether you revoke product access.</p>
<h3 id="heading-how-to-generate-and-run-migrations">How to Generate and Run Migrations</h3>
<p>After defining your schema, generate a migration file and apply it to your database:</p>
<pre><code class="language-bash"># Generate migration SQL from schema changes
bun run drizzle-kit generate

# Push schema directly (development only)
bun run drizzle-kit push

# Run migrations (production)
bun run drizzle-kit migrate
</code></pre>
<p>Drizzle Kit compares your TypeScript schema to the database and generates the SQL needed to bring them in sync. Review the generated migration file before running it in production. Schema changes are one of the few things you can't easily undo.</p>
<p>For development, <code>drizzle-kit push</code> is faster because it applies changes directly without creating migration files. For production, always use <code>drizzle-kit generate</code> followed by <code>drizzle-kit migrate</code> so you have a versioned record of every schema change.</p>
<h2 id="heading-how-to-create-stripe-products-and-prices">How to Create Stripe Products and Prices</h2>
<p>You can create products and prices through the Stripe dashboard, but managing them programmatically is better for reproducibility. Here's a seed script that creates everything you need:</p>
<pre><code class="language-typescript">// src/lib/payments/seed.ts
import { stripe } from "./index";

const PRODUCTS = [
  {
    name: "My SaaS Product",
    description: "Full access, one-time purchase",
    features: [
      "Full source code access",
      "Production-ready infrastructure",
      "Lifetime updates",
    ],
    metadata: { tier: "pro" },
    prices: [
      {
        lookupKey: "pro_one_time",
        unitAmount: 19900, // $199.00 in cents
        currency: "usd",
        nickname: "Pro One-Time",
      },
    ],
  },
];

async function main() {
  console.log("Seeding Stripe products and prices...\n");

  for (const config of PRODUCTS) {
    // Create or find product
    const products = await stripe.products.list({ active: true, limit: 100 });
    let product = products.data.find((p) =&gt; p.name === config.name);

    if (!product) {
      product = await stripe.products.create({
        name: config.name,
        description: config.description,
        marketing_features: config.features.map((f) =&gt; ({ name: f })),
        metadata: config.metadata,
      });
      console.log(`Created product "\({config.name}" (\){product.id})`);
    }

    // Create prices
    for (const priceConfig of config.prices) {
      const existing = await stripe.prices.list({
        lookup_keys: [priceConfig.lookupKey],
        active: true,
        limit: 1,
      });

      if (existing.data[0]) {
        console.log(`Price "${priceConfig.lookupKey}" already exists`);
        continue;
      }

      const price = await stripe.prices.create({
        product: product.id,
        unit_amount: priceConfig.unitAmount,
        currency: priceConfig.currency,
        nickname: priceConfig.nickname,
        lookup_key: priceConfig.lookupKey,
        transfer_lookup_key: true,
      });

      console.log(`Created price "\({priceConfig.lookupKey}" (\){price.id})`);
    }
  }

  console.log("\nDone! Add the price ID to your .env as STRIPE_PRO_PRICE_ID");
}

main().catch(console.error);
</code></pre>
<p>Run this with <code>bun run src/lib/payments/seed.ts</code>.</p>
<p>A few things worth noting.</p>
<ul>
<li><p><strong>Use</strong> <code>lookup_key</code> <strong>instead of hardcoding price IDs:</strong> Price IDs are different between test and live mode. Lookup keys let you reference prices by name (<code>pro_one_time</code>) rather than by Stripe's generated ID (<code>price_1P...</code>).  </p>
<p>The <code>transfer_lookup_key: true</code> option ensures that if you create a new price with the same lookup key, it replaces the old one automatically.</p>
</li>
<li><p><strong>Prices are in cents:</strong> Stripe's API expects amounts in the smallest currency unit. For USD, that means <code>19900</code> represents $199.00.  </p>
<p>This is a common source of bugs. Always store amounts in cents in your database and convert to dollars only at the display layer.</p>
</li>
<li><p><strong>The seed script is idempotent:</strong> You can run it multiple times safely. It checks for existing products and prices before creating new ones.</p>
</li>
</ul>
<h3 id="heading-how-to-set-up-the-stripe-client">How to Set Up the Stripe Client</h3>
<p>The Stripe client uses lazy initialization so that importing it doesn't throw if the API key is missing at module load time. This matters in build environments where environment variables aren't set.</p>
<pre><code class="language-typescript">// src/lib/payments/index.ts
import Stripe from "stripe";

let stripeClient: Stripe | null = null;

function getStripe(): Stripe {
  if (!stripeClient) {
    const secretKey = process.env.STRIPE_SECRET_KEY;
    if (!secretKey) {
      throw new Error("STRIPE_SECRET_KEY is not set");
    }
    stripeClient = new Stripe(secretKey);
  }
  return stripeClient;
}

export const stripe = new Proxy({} as Stripe, {
  get(_, prop) {
    return Reflect.get(getStripe(), prop);
  },
});
</code></pre>
<p>The <code>Proxy</code> wrapper is the key pattern here. Code across your application imports <code>stripe</code> and calls methods like <code>stripe.checkout.sessions.create(...)</code>. The proxy intercepts every property access and forwards it to the lazily initialized client.</p>
<p>This means the Stripe SDK only initializes when you actually use it, not when the module is imported.</p>
<h2 id="heading-how-to-build-the-checkout-flow">How to Build the Checkout Flow</h2>
<p>The checkout flow has three parts: creating the session, redirecting the customer, and handling the return.</p>
<h3 id="heading-how-to-create-a-checkout-session">How to Create a Checkout Session</h3>
<p>Here's the function that creates a Stripe Checkout session for a one-time payment:</p>
<pre><code class="language-typescript">// src/lib/payments/index.ts
export async function createOneTimeCheckoutSession(params: {
  priceId: string;
  successUrl: string;
  cancelUrl: string;
  metadata: Record&lt;string, string&gt;;
  customerEmail?: string;
  couponId?: string;
}) {
  const client = getStripe();

  const session = await client.checkout.sessions.create({
    mode: "payment",
    line_items: [{ price: params.priceId, quantity: 1 }],
    success_url: params.successUrl,
    cancel_url: params.cancelUrl,
    metadata: params.metadata,
    ...(params.customerEmail &amp;&amp; {
      customer_email: params.customerEmail,
    }),
    ...(params.couponId
      ? { discounts: [{ coupon: params.couponId }] }
      : { allow_promotion_codes: true }),
  });

  return session;
}
</code></pre>
<p>Three details matter here.</p>
<ul>
<li><p><strong>The</strong> <code>mode: "payment"</code> <strong>setting tells Stripe this is a one-time charge</strong>, not a subscription. For subscriptions, you would use <code>mode: "subscription"</code>. The mode affects which webhook events Stripe sends after payment.</p>
</li>
<li><p><strong>The</strong> <code>metadata</code> <strong>field is how you link the Stripe session back to your application.</strong> Pass your internal product tier, user ID, or any other data you need after payment. Stripe stores this metadata and includes it in webhook events and API responses.</p>
</li>
<li><p><strong>The</strong> <code>allow_promotion_codes: true</code> <strong>option shows a promo code field on the checkout page.</strong> If you have a specific coupon to apply (from a landing page URL parameter, for example), pass it via <code>discounts</code> instead. You can't use both at the same time.</p>
</li>
</ul>
<h3 id="heading-how-to-create-the-checkout-api-endpoint">How to Create the Checkout API Endpoint</h3>
<p>Here's the API endpoint that creates a checkout session and returns the URL:</p>
<pre><code class="language-typescript">// src/server/api.ts
app.post("/api/payments/checkout", async ({ set }) =&gt; {
  const priceId = process.env.STRIPE_PRO_PRICE_ID;

  if (!priceId) {
    set.status = 500;
    return { error: "Price not configured" };
  }

  const baseUrl = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
  const tier = "pro";

  const checkoutSession = await createOneTimeCheckoutSession({
    priceId,
    successUrl: `${baseUrl}/dashboard?purchase=success&amp;session_id={CHECKOUT_SESSION_ID}`,
    cancelUrl: `${baseUrl}/pricing`,
    metadata: { tier },
  });

  return { url: checkoutSession.url };
});
</code></pre>
<p>The <code>{CHECKOUT_SESSION_ID}</code> placeholder in the success URL is a Stripe template variable. Stripe replaces it with the actual session ID when redirecting the customer. This lets your frontend know which session just completed.</p>
<h3 id="heading-how-to-claim-the-purchase-after-checkout">How to Claim the Purchase After Checkout</h3>
<p>When the customer returns to your success URL, your frontend reads the <code>session_id</code> from the URL and sends it to a "claim" endpoint. This endpoint verifies the payment and creates the purchase record.</p>
<pre><code class="language-typescript">// src/server/api.ts
app.post(
  "/api/purchases/claim",
  async ({ body, request, set }) =&gt; {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session) {
      set.status = 401;
      return { error: "Unauthorized" };
    }

    const { sessionId } = body;

    // Check if this session was already claimed
    const existing = await db
      .select()
      .from(purchases)
      .where(eq(purchases.stripeCheckoutSessionId, sessionId))
      .limit(1);

    if (existing[0]) {
      return { success: true, alreadyClaimed: true, tier: existing[0].tier };
    }

    // Retrieve the Stripe checkout session to verify payment
    const stripeSession = await retrieveCheckoutSession(sessionId);

    if (stripeSession.payment_status !== "paid") {
      set.status = 400;
      return { error: "Payment not completed" };
    }

    const tier = (stripeSession.metadata?.tier ?? "pro") as PaymentTier;

    // Create purchase record
    await db.insert(purchases).values({
      userId: session.user.id,
      stripeCheckoutSessionId: sessionId,
      stripeCustomerId:
        typeof stripeSession.customer === "string"
          ? stripeSession.customer
          : stripeSession.customer?.id ?? null,
      stripePaymentIntentId:
        typeof stripeSession.payment_intent === "string"
          ? stripeSession.payment_intent
          : stripeSession.payment_intent?.id ?? null,
      tier,
      status: "completed",
      amount: stripeSession.amount_total ?? 0,
      currency: stripeSession.currency ?? "usd",
    });

    // Trigger background processing
    await inngest.send({
      name: "purchase/completed",
      data: {
        userId: session.user.id,
        tier,
        sessionId,
      },
    });

    return { success: true, tier };
  },
  {
    body: t.Object({
      sessionId: t.String(),
    }),
  }
);
</code></pre>
<p>This endpoint does four things, in order.</p>
<ol>
<li><p><strong>First, it checks if the session was already claimed.</strong> The <code>unique()</code> constraint on <code>stripeCheckoutSessionId</code> in the schema prevents duplicate records, but checking first lets you return a clean response without catching a database error.</p>
</li>
<li><p><strong>Second, it verifies payment with Stripe.</strong> Never trust data from the client. The frontend passes the session ID, but you must call Stripe's API to confirm that <code>payment_status</code> is <code>"paid"</code>.</p>
</li>
<li><p><strong>Third, it creates the purchase record.</strong> Notice how it extracts the <code>customer</code> and <code>payment_intent</code> from the Stripe session. Both fields are returned as either strings or expanded objects depending on your Stripe API settings, so the ternary handles both cases.</p>
</li>
<li><p><strong>Fourth, it sends a</strong> <code>purchase/completed</code> <strong>event to Inngest.</strong> This triggers the background processing flow that handles emails, access grants, analytics, and follow-up scheduling. The API endpoint doesn't do any of that work and returns <code>{ success: true }</code> immediately.</p>
</li>
</ol>
<p>This separation between recording the purchase and processing it is fundamental. The database insert is fast and reliable. The downstream processing (emails, API calls, analytics) is slow and unreliable.</p>
<p>By splitting them, you ensure the customer sees a success response instantly while the background work happens durably.</p>
<h2 id="heading-how-to-handle-webhooks-securely">How to Handle Webhooks Securely</h2>
<p>Your webhook endpoint is the entry point for Stripe events that happen outside your checkout flow: refunds, expired sessions, and disputes.</p>
<h3 id="heading-how-to-verify-webhook-signatures">How to Verify Webhook Signatures</h3>
<p>Every webhook from Stripe includes a signature header. You must verify this signature before processing the event. Without verification, anyone could send fake events to your webhook URL.</p>
<pre><code class="language-typescript">// src/lib/payments/index.ts
export async function constructWebhookEvent(
  payload: string | Buffer,
  signature: string
) {
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
  if (!webhookSecret) {
    throw new Error("STRIPE_WEBHOOK_SECRET is not set");
  }
  const client = getStripe();
  return client.webhooks.constructEventAsync(payload, signature, webhookSecret);
}
</code></pre>
<p>One critical detail: <strong>use</strong> <code>constructEventAsync</code> <strong>instead of</strong> <code>constructEvent</code><strong>.</strong> The async version uses the Web Crypto API, which is compatible with modern runtimes like Bun and Cloudflare Workers. The synchronous version depends on Node.js's <code>crypto</code> module, which isn't available everywhere.</p>
<p>Another critical detail: <strong>pass the raw request body to signature verification.</strong> If your framework parses the body as JSON before you access it, the signature check fails. The signature is computed over the raw bytes of the request, not the parsed JSON.</p>
<h3 id="heading-how-to-build-the-webhook-endpoint">How to Build the Webhook Endpoint</h3>
<p>Here is the production webhook handler. Its only job is to validate the event and route it to the background job system.</p>
<pre><code class="language-typescript">// src/server/api.ts
app.post("/api/payments/webhook", async ({ request, set }) =&gt; {
  const body = await request.text();
  const sig = request.headers.get("stripe-signature");

  if (!sig) {
    set.status = 400;
    return { error: "Missing signature" };
  }

  try {
    const event = await constructWebhookEvent(body, sig);
    console.log(`[Webhook] Received ${event.type}`);

    if (event.type === "charge.refunded") {
      const charge = event.data.object as {
        id: string;
        payment_intent: string;
        amount: number;
        amount_refunded: number;
        currency: string;
      };
      await inngest.send({
        name: "stripe/charge.refunded",
        data: {
          chargeId: charge.id,
          paymentIntentId: charge.payment_intent,
          amountRefunded: charge.amount_refunded,
          originalAmount: charge.amount,
          currency: charge.currency,
        },
      });
    }

    if (event.type === "checkout.session.expired") {
      const session = event.data.object as {
        id: string;
        customer_email: string | null;
      };
      await inngest.send({
        name: "stripe/checkout.session.expired",
        data: {
          sessionId: session.id,
          customerEmail: session.customer_email,
        },
      });
    }

    return { received: true };
  } catch (error) {
    console.error("[Webhook] Stripe verification failed:", error);
    set.status = 400;
    return { error: "Webhook verification failed" };
  }
});
</code></pre>
<p>This is the "thin webhook handler" pattern. Notice what it does <strong>not</strong> do: it does not query the database, send emails, grant access, or call any external service. It validates the signature, extracts the fields it needs, and sends a typed event to Inngest.</p>
<p>The entire handler completes in milliseconds.</p>
<p>Why does this matter? Stripe expects your webhook to return a 2xx response within about 20 seconds. If your handler tries to do too much work (database queries, email sends, API calls), it risks timing out.</p>
<p>Stripe marks it as failed and retries the entire event. Now you have partial completion and duplicate processing.</p>
<p>The thin handler avoids this entirely. Validate, enqueue, return. All the real work happens asynchronously in durable background functions.</p>
<h3 id="heading-why-extract-fields-before-enqueueing">Why Extract Fields Before Enqueueing?</h3>
<p>You might notice that the webhook handler extracts specific fields from the Stripe event before sending them to Inngest:</p>
<pre><code class="language-typescript">await inngest.send({
  name: "stripe/charge.refunded",
  data: {
    chargeId: charge.id,
    paymentIntentId: charge.payment_intent,
    amountRefunded: charge.amount_refunded,
    originalAmount: charge.amount,
    currency: charge.currency,
  },
});
</code></pre>
<p>Why not forward the entire Stripe event? Two reasons.</p>
<p>First, Stripe event objects are large and deeply nested. Your background function only needs five fields. Sending the entire object means your durable function stores a large payload at every checkpoint, and over thousands of runs, this adds up.</p>
<p>Second, extracting fields at the boundary creates a clean contract between your webhook handler and your background functions. If Stripe changes the shape of their event objects in a future API version, you only need to update the extraction logic in the webhook handler. Your background functions keep working because they depend on your own typed data shape, not Stripe's.</p>
<h3 id="heading-how-to-set-up-webhooks-in-production">How to Set Up Webhooks in Production</h3>
<p>For production, you configure webhooks in the Stripe Dashboard:</p>
<ol>
<li><p>Go to Stripe Dashboard, then Developers, then Webhooks.</p>
</li>
<li><p>Add an endpoint pointing to your production URL: <code>https://yourapp.com/api/payments/webhook</code>.</p>
</li>
<li><p>Select the events you want to receive: <code>charge.refunded</code> and <code>checkout.session.expired</code>.</p>
</li>
<li><p>Copy the signing secret and add it to your production environment variables as <code>STRIPE_WEBHOOK_SECRET</code>.</p>
</li>
</ol>
<p>The production signing secret is different from the one the Stripe CLI generates for local testing. Make sure your environment variables are set correctly for each environment.</p>
<h3 id="heading-which-webhook-events-to-listen-for">Which Webhook Events to Listen For</h3>
<p>For a complete payment flow, you need these webhook events configured in Stripe:</p>
<table>
<thead>
<tr>
<th>Event</th>
<th>When It Fires</th>
<th>What You Do</th>
</tr>
</thead>
<tbody><tr>
<td><code>charge.refunded</code></td>
<td>Customer receives a refund</td>
<td>Revoke access (full refund) or update status (partial)</td>
</tr>
<tr>
<td><code>checkout.session.expired</code></td>
<td>Checkout session times out (24 hours)</td>
<td>Send abandoned cart recovery email</td>
</tr>
</tbody></table>
<p>For subscription-based billing, you would also listen for <code>customer.subscription.updated</code>, <code>customer.subscription.deleted</code>, and <code>invoice.payment_failed</code>. This article covers one-time payments, so the examples focus on the two events above.</p>
<p>The <code>checkout.session.completed</code> event is notably absent. For one-time payments, you typically process the purchase in the "claim" endpoint (shown in the previous section) rather than in a webhook, because you need the authenticated user's session to link the purchase to their account.</p>
<h2 id="heading-how-to-process-purchases-with-durable-background-jobs">How to Process Purchases with Durable Background Jobs</h2>
<p>This is the heart of the payment flow. After the purchase record is created and the <code>purchase/completed</code> event is sent, a durable function takes over and runs the entire post-payment workflow.</p>
<p>Each step in this function is individually checkpointed. If step 5 fails, steps 1 through 4 don't re-run. Step 5 retries on its own, and once it succeeds, steps 6 through 9 continue.</p>
<p>This is what "durable execution" means. It's the difference between a payment system that works in development and one that works in production.</p>
<p>I use <a href="https://www.inngest.com/">Inngest</a> for this. It is an event-driven durable execution platform that provides step-level checkpointing out of the box. You define functions with <code>step.run()</code> blocks, and Inngest handles retry logic, state persistence, and observability.</p>
<p>The Inngest client setup is minimal:</p>
<pre><code class="language-typescript">// src/lib/jobs/client.ts
import { Inngest } from "inngest";

export const inngest = new Inngest({
  id: "my-app",
});
</code></pre>
<p>Register your functions with the Inngest serve handler so the dev server (and production) can discover them:</p>
<pre><code class="language-typescript">import { serve } from "inngest/bun";
import { inngest } from "@/lib/jobs/client";
import { stripeFunctions } from "@/lib/jobs/functions/stripe";

const inngestHandler = serve({
  client: inngest,
  functions: [...stripeFunctions],
});

// Mount on your API
app.all("/api/inngest", async (ctx) =&gt; {
  return inngestHandler(ctx.request);
});
</code></pre>
<p>Here's the complete purchase function:</p>
<pre><code class="language-typescript">// src/lib/jobs/functions/stripe.ts
import { eq } from "drizzle-orm";
import { createElement } from "react";

import { inngest } from "../client";
import { trackServerEvent } from "@/lib/analytics/server";
import { brand } from "@/lib/brand";
import { db, purchases, users } from "@/lib/db";
import {
  sendEmail,
  PurchaseConfirmationEmail,
  AdminPurchaseNotificationEmail,
  RepoAccessGrantedEmail,
} from "@/lib/email";
import { addCollaborator } from "@/lib/github";

export const handlePurchaseCompleted = inngest.createFunction(
  { id: "purchase-completed", triggers: [{ event: "purchase/completed" }] },
  async ({ event, step }) =&gt; {
    const { userId, tier, sessionId } = event.data as {
      userId: string;
      tier: string;
      sessionId: string;
    };

    // Step 1: Look up user and purchase details
    const { user, purchase } = await step.run(
      "lookup-user-and-purchase",
      async () =&gt; {
        const userResult = await db
          .select({
            id: users.id,
            email: users.email,
            name: users.name,
            githubUsername: users.githubUsername,
          })
          .from(users)
          .where(eq(users.id, userId))
          .limit(1);

        const foundUser = userResult[0];
        if (!foundUser) {
          throw new Error(`User not found: ${userId}`);
        }

        const purchaseResult = await db
          .select({
            amount: purchases.amount,
            currency: purchases.currency,
            stripePaymentIntentId: purchases.stripePaymentIntentId,
          })
          .from(purchases)
          .where(eq(purchases.stripeCheckoutSessionId, sessionId))
          .limit(1);

        const foundPurchase = purchaseResult[0];

        return {
          user: foundUser,
          purchase: foundPurchase ?? {
            amount: 0,
            currency: "usd",
            stripePaymentIntentId: null,
          },
        };
      }
    );

    // Step 2: Track purchase in analytics
    await step.run("track-purchase-to-posthog", async () =&gt; {
      try {
        await trackServerEvent(userId, "purchase_completed_server", {
          tier,
          amount_cents: purchase.amount,
          currency: purchase.currency,
          stripe_session_id: sessionId,
          stripe_payment_intent_id: purchase.stripePaymentIntentId,
        });
      } catch (error) {
        console.error(`Failed to track to PostHog:`, error);
      }
    });

    // Step 3: Send purchase confirmation to customer
    await step.run("send-purchase-confirmation", async () =&gt; {
      await sendEmail({
        to: user.email,
        subject: `Your ${brand.name} purchase is confirmed!`,
        template: createElement(PurchaseConfirmationEmail, {
          amount: purchase.amount,
          currency: purchase.currency,
          customerEmail: user.email,
        }),
      });
    });

    // Step 4: Send admin notification
    await step.run("send-admin-notification", async () =&gt; {
      const adminEmail = process.env.ADMIN_EMAIL;
      if (!adminEmail) return;

      await sendEmail({
        to: adminEmail,
        subject: `New template sale: ${user.email}`,
        template: createElement(AdminPurchaseNotificationEmail, {
          amount: purchase.amount,
          currency: purchase.currency,
          customerEmail: user.email,
          customerName: user.name,
          stripeSessionId: purchase.stripePaymentIntentId ?? sessionId,
        }),
      });
    });

    // Early return if user has no GitHub username
    if (!user.githubUsername) {
      return { success: true, userId, tier, githubAccessGranted: false };
    }

    // Step 5: Grant GitHub repository access
    const collaboratorResult = await step.run(
      "add-github-collaborator",
      async () =&gt; {
        return addCollaborator(user.githubUsername!);
      }
    );

    // Step 6: Track GitHub access granted
    await step.run("track-github-access", async () =&gt; {
      await trackServerEvent(userId, "github_access_granted", {
        tier,
        github_username: user.githubUsername,
        invitation_status: collaboratorResult.status,
      });
    });

    // Step 7: Update purchase record
    await step.run("update-purchase-record", async () =&gt; {
      await db
        .update(purchases)
        .set({
          githubAccessGranted: true,
          githubInvitationId: collaboratorResult.status,
          updatedAt: new Date(),
        })
        .where(eq(purchases.stripeCheckoutSessionId, sessionId));
    });

    // Step 8: Send repo access email
    await step.run("send-repo-access-email", async () =&gt; {
      const repoUrl = brand.social.github;
      await sendEmail({
        to: user.email,
        subject: `Your ${brand.name} repository access is ready!`,
        template: createElement(RepoAccessGrantedEmail, { repoUrl }),
      });
    });

    // Step 9: Schedule follow-up email sequence
    await step.run("schedule-follow-up", async () =&gt; {
      const purchaseRecord = await db
        .select({ id: purchases.id })
        .from(purchases)
        .where(eq(purchases.stripeCheckoutSessionId, sessionId))
        .limit(1);

      if (purchaseRecord[0]) {
        await inngest.send({
          name: "purchase/follow-up.scheduled",
          data: {
            userId,
            purchaseId: purchaseRecord[0].id,
            tier,
          },
        });
      }
    });

    return { success: true, userId, tier, githubAccessGranted: true };
  }
);
</code></pre>
<p>That's a lot of code. Let me break down why each step exists and why it must be separate.</p>
<h3 id="heading-step-1-look-up-user-and-purchase">Step 1: Look Up User and Purchase</h3>
<pre><code class="language-typescript">const { user, purchase } = await step.run(
  "lookup-user-and-purchase",
  async () =&gt; {
    // Database queries for user and purchase records
    return { user: foundUser, purchase: foundPurchase };
  }
);
</code></pre>
<p>This step queries the database for the user and purchase details. Every subsequent step depends on these values (the user's email, the purchase amount, the user's GitHub username).</p>
<p>Because this is wrapped in <code>step.run()</code>, the return value is cached by Inngest. If a later step fails and the function retries, this step doesn't re-run. The cached values are replayed instead.</p>
<p>If the user doesn't exist in the database, this step throws an error that halts the entire function. There's no point continuing if the user can't be found.</p>
<h3 id="heading-step-2-track-analytics">Step 2: Track Analytics</h3>
<pre><code class="language-typescript">await step.run("track-purchase-to-posthog", async () =&gt; {
  try {
    await trackServerEvent(userId, "purchase_completed_server", {
      tier,
      amount_cents: purchase.amount,
      currency: purchase.currency,
    });
  } catch (error) {
    console.error(`Failed to track to PostHog:`, error);
  }
});
</code></pre>
<p>Analytics tracking gets its own step because analytics services have their own failure modes. PostHog could be rate-limited or temporarily unreachable. If that happens, you don't want it to block the confirmation email.</p>
<p>Notice the try-catch. A tracking failure logs the error but doesn't halt the function. Analytics data is valuable but not critical to the purchase flow.</p>
<h3 id="heading-steps-3-and-4-email-notifications">Steps 3 and 4: Email Notifications</h3>
<p>The customer confirmation and admin notification are separate steps because they are independent operations. If Resend returns a 500 when sending the admin email, the customer should still get their confirmation.</p>
<pre><code class="language-typescript">// Step 3: Customer confirmation
await step.run("send-purchase-confirmation", async () =&gt; {
  await sendEmail({
    to: user.email,
    subject: `Your ${brand.name} purchase is confirmed!`,
    template: createElement(PurchaseConfirmationEmail, {
      amount: purchase.amount,
      currency: purchase.currency,
      customerEmail: user.email,
    }),
  });
});

// Step 4: Admin notification
await step.run("send-admin-notification", async () =&gt; {
  const adminEmail = process.env.ADMIN_EMAIL;
  if (!adminEmail) return;

  await sendEmail({
    to: adminEmail,
    subject: `New template sale: ${user.email}`,
    template: createElement(AdminPurchaseNotificationEmail, {
      // ... admin-specific fields
    }),
  });
});
</code></pre>
<p>The admin notification step includes a guard: if <code>ADMIN_EMAIL</code> isn't set, it returns early. This makes the function work in development environments where you haven't configured all environment variables.</p>
<h3 id="heading-step-5-grant-product-access">Step 5: Grant Product Access</h3>
<pre><code class="language-typescript">if (!user.githubUsername) {
  return { success: true, userId, tier, githubAccessGranted: false };
}

const collaboratorResult = await step.run(
  "add-github-collaborator",
  async () =&gt; {
    return addCollaborator(user.githubUsername!);
  }
);
</code></pre>
<p>This is the step most likely to fail. GitHub's API has rate limits, can time out, and the user's GitHub username might be invalid.</p>
<p>By making it its own step, a GitHub API failure doesn't re-trigger the confirmation email (step 3) or the admin notification (step 4). Those are already checkpointed.</p>
<p>Notice the early return before step 5. If the user has no GitHub username linked, the function returns after step 4. The remaining steps only run when there's a GitHub account to grant access to.</p>
<h3 id="heading-steps-6-7-track-and-update">Steps 6-7: Track and Update</h3>
<p>After granting GitHub access, the function tracks the event in analytics (step 6) and updates the purchase record in the database (step 7).</p>
<p>The database update is intentionally ordered after the GitHub API call. You only set <code>githubAccessGranted: true</code> after the invitation actually succeeded. If you updated the record first and the GitHub step failed, your database would say access was granted when it was not.</p>
<h3 id="heading-step-8-send-access-email">Step 8: Send Access Email</h3>
<pre><code class="language-typescript">await step.run("send-repo-access-email", async () =&gt; {
  const repoUrl = brand.social.github;
  await sendEmail({
    to: user.email,
    subject: `Your ${brand.name} repository access is ready!`,
    template: createElement(RepoAccessGrantedEmail, { repoUrl }),
  });
});
</code></pre>
<p>This email only sends after the GitHub invitation is confirmed. The ordering is deliberate. You don't tell the customer "your access is ready" if the invitation hasn't been sent.</p>
<h3 id="heading-step-9-schedule-follow-up-sequence">Step 9: Schedule Follow-Up Sequence</h3>
<pre><code class="language-typescript">await step.run("schedule-follow-up", async () =&gt; {
  const purchaseRecord = await db
    .select({ id: purchases.id })
    .from(purchases)
    .where(eq(purchases.stripeCheckoutSessionId, sessionId))
    .limit(1);

  if (purchaseRecord[0]) {
    await inngest.send({
      name: "purchase/follow-up.scheduled",
      data: {
        userId,
        purchaseId: purchaseRecord[0].id,
        tier,
      },
    });
  }
});
</code></pre>
<p>The final step triggers a separate function that handles the follow-up email sequence: day 7 onboarding tips, day 14 feedback request, day 30 testimonial request. This is an event-driven chain: one function completes and triggers another.</p>
<p>The follow-up function uses <code>step.sleep()</code> to wait between emails without consuming compute resources:</p>
<pre><code class="language-typescript">export const handlePurchaseFollowUp = inngest.createFunction(
  {
    id: "purchase-follow-up",
    triggers: [{ event: "purchase/follow-up.scheduled" }],
    cancelOn: [
      {
        event: "purchase/follow-up.cancelled",
        match: "data.purchaseId",
      },
    ],
  },
  async ({ event, step }) =&gt; {
    await step.sleep("wait-7-days", "7d");
    await step.run("send-day-7-email", async () =&gt; {
      // Send onboarding tips
    });

    await step.sleep("wait-14-days", "7d");
    await step.run("send-day-14-email", async () =&gt; {
      // Send feedback request
    });
  }
);
</code></pre>
<p>The <code>cancelOn</code> option is worth noting. If the purchase is refunded, you send a <code>purchase/follow-up.cancelled</code> event, and the entire follow-up sequence stops. No stale emails to customers who refunded.</p>
<h3 id="heading-the-rule-for-step-separation">The Rule for Step Separation</h3>
<p>Any operation that calls an external service or could fail independently should be its own step. A database query is a step because the database can be temporarily unreachable. An email send or API call is a step because those services can return errors or hit rate limits.</p>
<p>If two operations always succeed or fail together, they can share a step. But when in doubt, make it separate. The overhead is negligible, and the reliability gain is significant.</p>
<h2 id="heading-how-to-handle-refunds">How to Handle Refunds</h2>
<p>Refund processing is the most commonly overlooked part of a payment system. You need to handle two cases: full refunds (revoke access) and partial refunds (keep access, update status).</p>
<p>Here's the complete refund handler:</p>
<pre><code class="language-typescript">// src/lib/jobs/functions/stripe.ts
export const handleRefund = inngest.createFunction(
  { id: "refund-processed", triggers: [{ event: "stripe/charge.refunded" }] },
  async ({ event, step }) =&gt; {
    const data = event.data as {
      chargeId: string;
      paymentIntentId: string;
      amountRefunded: number;
      originalAmount: number;
      currency: string;
    };

    const chargeId = data.chargeId;
    const paymentIntentId = data.paymentIntentId;
    const currency = data.currency;
    const amountRefunded = data.amountRefunded;
    const originalAmount = data.originalAmount;
    const isFullRefund = amountRefunded &gt;= originalAmount;

    // Step 1: Look up the purchase and user
    const { user, purchase } = await step.run(
      "lookup-purchase-by-payment-intent",
      async () =&gt; {
        const purchaseResult = await db
          .select({
            id: purchases.id,
            userId: purchases.userId,
            stripePaymentIntentId: purchases.stripePaymentIntentId,
            githubAccessGranted: purchases.githubAccessGranted,
          })
          .from(purchases)
          .where(eq(purchases.stripePaymentIntentId, paymentIntentId))
          .limit(1);

        const foundPurchase = purchaseResult[0];
        if (!foundPurchase) {
          return { user: null, purchase: null };
        }

        const userResult = await db
          .select({
            id: users.id,
            email: users.email,
            name: users.name,
            githubUsername: users.githubUsername,
          })
          .from(users)
          .where(eq(users.id, foundPurchase.userId))
          .limit(1);

        return { user: userResult[0] ?? null, purchase: foundPurchase };
      }
    );

    if (!purchase || !user) {
      return { success: false, reason: "no_matching_purchase" };
    }

    let accessRevoked = false;

    // Step 2: Revoke GitHub access (only for full refunds)
    if (isFullRefund &amp;&amp; user.githubUsername &amp;&amp; purchase.githubAccessGranted) {
      const revokeResult = await step.run(
        "revoke-github-access",
        async () =&gt; {
          return removeCollaborator(user.githubUsername!);
        }
      );
      accessRevoked = revokeResult.success;
    }

    // Step 3: Update purchase status
    await step.run("update-purchase-status", async () =&gt; {
      if (isFullRefund) {
        await db
          .update(purchases)
          .set({
            status: "refunded",
            githubAccessGranted: false,
            updatedAt: new Date(),
          })
          .where(eq(purchases.id, purchase.id));
      } else {
        await db
          .update(purchases)
          .set({
            status: "partially_refunded",
            updatedAt: new Date(),
          })
          .where(eq(purchases.id, purchase.id));
      }
    });

    // Step 4: Track refund in analytics
    await step.run("track-refund-event", async () =&gt; {
      try {
        await trackServerEvent(user.id, "refund_processed", {
          charge_id: chargeId,
          payment_intent_id: paymentIntentId,
          amount_cents: amountRefunded,
          original_amount_cents: originalAmount,
          currency,
          is_full_refund: isFullRefund,
          github_access_revoked: accessRevoked,
        });
      } catch (error) {
        console.error(`Failed to track to PostHog:`, error);
      }
    });

    // Step 5: Notify customer
    await step.run("send-customer-notification", async () =&gt; {
      if (isFullRefund) {
        await sendEmail({
          to: user.email,
          subject: `Your ${brand.name} refund has been processed`,
          template: createElement(AccessRevokedEmail, {
            customerEmail: user.email,
            refundAmount: amountRefunded,
            currency,
          }),
        });
      } else {
        await sendEmail({
          to: user.email,
          subject: `Your ${brand.name} partial refund has been processed`,
          template: createElement(PartialRefundEmail, {
            customerEmail: user.email,
            refundAmount: amountRefunded,
            originalAmount,
            currency,
          }),
        });
      }
    });

    // Step 6: Notify admin
    await step.run("send-admin-notification", async () =&gt; {
      const adminEmail = process.env.ADMIN_EMAIL;
      if (!adminEmail) return;

      await sendEmail({
        to: adminEmail,
        subject: `\({isFullRefund ? "Full" : "Partial"} refund processed: \){user.email}`,
        template: createElement(AdminRefundNotificationEmail, {
          customerEmail: user.email,
          customerName: user.name,
          githubUsername: user.githubUsername,
          refundAmount: amountRefunded,
          originalAmount,
          currency,
          stripeChargeId: chargeId,
          accessRevoked,
          isPartialRefund: !isFullRefund,
        }),
      });
    });

    return { success: true, accessRevoked, isFullRefund, userId: user.id };
  }
);
</code></pre>
<h3 id="heading-how-full-refunds-differ-from-partial-refunds">How Full Refunds Differ from Partial Refunds</h3>
<p>The function distinguishes between the two with a simple comparison:</p>
<pre><code class="language-typescript">const isFullRefund = amountRefunded &gt;= originalAmount;
</code></pre>
<p>For a <strong>full refund</strong>, three things happen:</p>
<ol>
<li><p>GitHub access is revoked (the <code>removeCollaborator</code> call).</p>
</li>
<li><p>The purchase status is set to <code>"refunded"</code>.</p>
</li>
<li><p>The customer receives an <code>AccessRevokedEmail</code> explaining that their access has been removed.</p>
</li>
</ol>
<p>For a <strong>partial refund</strong>, the customer keeps access:</p>
<ol>
<li><p>GitHub access is <strong>not</strong> revoked.</p>
</li>
<li><p>The purchase status is set to <code>"partially_refunded"</code>.</p>
</li>
<li><p>The customer receives a <code>PartialRefundEmail</code> showing the refunded amount and the original amount.</p>
</li>
</ol>
<p>This distinction matters for your database integrity. Downstream systems (your dashboard, analytics, support tools) need accurate status values. A <code>partially_refunded</code> purchase still represents an active customer.</p>
<h3 id="heading-how-conditional-steps-work">How Conditional Steps Work</h3>
<p>The "revoke GitHub access" step only runs when three conditions are all true: it's a full refund, the user has a GitHub username, and access was previously granted.</p>
<pre><code class="language-typescript">if (isFullRefund &amp;&amp; user.githubUsername &amp;&amp; purchase.githubAccessGranted) {
  const revokeResult = await step.run("revoke-github-access", async () =&gt; {
    return removeCollaborator(user.githubUsername!);
  });
  accessRevoked = revokeResult.success;
}
</code></pre>
<p>If any of those conditions is false, the step is skipped entirely. Inngest handles this cleanly. The function continues to step 3 (update purchase status) with <code>accessRevoked</code> still set to <code>false</code>.</p>
<h2 id="heading-how-to-recover-abandoned-checkouts">How to Recover Abandoned Checkouts</h2>
<p>When a customer starts checkout but doesn't complete it, Stripe eventually expires the session (after 24 hours by default). You can listen for this event and send a recovery email.</p>
<p>The key insight is that you don't want to send the email immediately. Give the customer an hour to come back on their own.</p>
<pre><code class="language-typescript">// src/lib/jobs/functions/stripe.ts
export const handleCheckoutExpired = inngest.createFunction(
  {
    id: "checkout-expired",
    triggers: [{ event: "stripe/checkout.session.expired" }],
  },
  async ({ event, step }) =&gt; {
    const { customerEmail, sessionId } = event.data as {
      customerEmail: string | null;
      sessionId: string;
    };

    if (!customerEmail) {
      return { success: false, reason: "no_email" };
    }

    // Wait 1 hour before sending recovery email
    await step.sleep("wait-before-recovery-email", "1h");

    // Send abandoned cart email
    await step.run("send-abandoned-cart-email", async () =&gt; {
      const baseUrl =
        process.env.BETTER_AUTH_URL ?? "https://your-app.com";
      const checkoutUrl = `${baseUrl}/pricing`;

      await sendEmail({
        to: customerEmail,
        subject: `Your ${brand.name} checkout is waiting`,
        template: createElement(AbandonedCartEmail, {
          customerEmail,
          checkoutUrl,
        }),
      });
    });

    // Track the recovery attempt
    await step.run("track-abandoned-cart", async () =&gt; {
      try {
        await trackServerEvent("anonymous", "abandoned_cart_email_sent", {
          customer_email: customerEmail,
          session_id: sessionId,
        });
      } catch (error) {
        console.error(`Failed to track to PostHog:`, error);
      }
    });

    return { success: true, customerEmail };
  }
);
</code></pre>
<p>The <code>step.sleep("wait-before-recovery-email", "1h")</code> line pauses the function for one hour without consuming compute resources. Inngest schedules the function to resume after the delay. No cron jobs, no Redis queues, no <code>setTimeout</code> that gets lost when your server restarts.</p>
<p>There is a guard at the top of the function. If the checkout session has no customer email (the customer closed the page before entering their email), the function returns early. You can't send a recovery email without an address.</p>
<p>You could extend this pattern with a second sleep and follow-up email three days later. You could also check if the customer has since completed a purchase (by querying the database in a <code>step.run()</code>) and skip the email if they have.</p>
<h3 id="heading-why-one-hour-is-the-right-delay">Why One Hour Is the Right Delay</h3>
<p>Sending the recovery email immediately after checkout expiration feels aggressive. The customer might still be comparing options, waiting for payday, or just distracted. An immediate email says "we noticed you left," which feels surveillance-like.</p>
<p>Waiting 24 hours is too long. The customer has moved on. They have forgotten your product or found an alternative.</p>
<p>One hour is the sweet spot I found through testing. The customer's intent is still fresh, and the email feels helpful rather than pushy.</p>
<p>Your mileage may vary. The delay is configurable: change <code>"1h"</code> to <code>"30m"</code> or <code>"3h"</code> and redeploy.</p>
<h3 id="heading-why-this-is-better-than-a-cron-job">Why This Is Better Than a Cron Job</h3>
<p>Without durable execution, abandoned cart recovery typically works like this: a cron job runs every hour, queries the database for expired sessions that haven't been recovered yet, sends emails to each one, and marks them as recovered.</p>
<p>This approach has several problems. You need a <code>recovered_at</code> column to avoid sending duplicate emails. You need to handle the case where the cron job crashes halfway through the batch, and you need to tune the cron interval carefully.</p>
<p>The <code>step.sleep()</code> approach eliminates all of this. Each expired session gets its own function instance with its own timer. There's no batch processing, no database flag, and no duplicate risk.</p>
<h2 id="heading-how-to-send-transactional-emails-with-react-email">How to Send Transactional Emails with React Email</h2>
<p>Every email in the payment flow is a React component rendered to HTML and sent via Resend. This gives you type-safe templates with props, component reuse, and the ability to preview emails in your browser during development.</p>
<h3 id="heading-how-to-set-up-the-email-client">How to Set Up the Email Client</h3>
<p>The email client wraps Resend with a simple <code>sendEmail</code> function:</p>
<pre><code class="language-typescript">// src/lib/email/index.ts
import { render } from "@react-email/components";
import type { ReactElement } from "react";
import { Resend } from "resend";

import { brand } from "@/lib/brand";

let resendClient: Resend | null = null;

function getResend(): Resend {
  if (!resendClient) {
    const apiKey = process.env.RESEND_API_KEY;
    if (!apiKey) {
      throw new Error("RESEND_API_KEY is not set");
    }
    resendClient = new Resend(apiKey);
  }
  return resendClient;
}

interface SendEmailOptions {
  to: string | string[];
  subject: string;
  template: ReactElement;
  from?: string;
  replyTo?: string;
}

export async function sendEmail({
  to,
  subject,
  template,
  from = process.env.EMAIL_FROM ?? brand.emails.from,
  replyTo,
}: SendEmailOptions) {
  const resend = getResend();
  const html = await render(template);

  return resend.emails.send({
    from,
    to,
    subject,
    html,
    replyTo,
  });
}
</code></pre>
<p>The <code>render()</code> function from <code>@react-email/components</code> converts a React element into an HTML string. This HTML is what Resend delivers to the customer's inbox.</p>
<p>The <code>from</code> address defaults to your brand's email configuration. You need a verified domain in Resend for this to work. During development, Resend's free tier lets you send to your own email address without domain verification.</p>
<h3 id="heading-how-to-build-a-purchase-confirmation-template">How to Build a Purchase Confirmation Template</h3>
<p>Here's the real purchase confirmation email template:</p>
<pre><code class="language-tsx">// src/lib/email/emails/purchase-confirmation.tsx
import {
  Body,
  Container,
  Head,
  Heading,
  Hr,
  Html,
  Link,
  Preview,
  Section,
  Text,
} from "@react-email/components";

import { brand } from "@/lib/brand";

interface PurchaseConfirmationEmailProps {
  amount: number;
  currency: string;
  customerEmail: string;
}

const colors = {
  primary: "#d97757",
  background: "#faf9f5",
  foreground: "#30302e",
  muted: "#6b6860",
  border: "#e5e4df",
  card: "#ffffff",
  success: "#16a34a",
  successLight: "#f0fdf4",
};

export default function PurchaseConfirmationEmail({
  amount,
  currency,
  customerEmail,
}: PurchaseConfirmationEmailProps) {
  const formattedAmount = new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: currency.toUpperCase(),
  }).format(amount / 100);

  return (
    &lt;Html&gt;
      &lt;Head /&gt;
      &lt;Preview&gt;Your {brand.name} purchase is confirmed!&lt;/Preview&gt;
      &lt;Body style={main}&gt;
        &lt;Container style={container}&gt;
          &lt;Section style={header}&gt;
            &lt;Text style={logoText}&gt;{brand.name}&lt;/Text&gt;
          &lt;/Section&gt;

          &lt;Hr style={divider} /&gt;

          &lt;Section style={successBadge}&gt;
            &lt;Text style={successText}&gt;Payment Successful&lt;/Text&gt;
          &lt;/Section&gt;

          &lt;Heading style={h1}&gt;Thank you for your purchase!&lt;/Heading&gt;

          &lt;Text style={text}&gt;
            Your payment has been processed successfully. We are now setting
            up your GitHub repository access. You will receive another email
            shortly with your access link.
          &lt;/Text&gt;

          &lt;Section style={detailsBox}&gt;
            &lt;Text style={detailsTitle}&gt;Order Details&lt;/Text&gt;

            &lt;Section style={detailRow}&gt;
              &lt;Text style={detailLabel}&gt;Product&lt;/Text&gt;
              &lt;Text style={detailValue}&gt;{brand.name}&lt;/Text&gt;
            &lt;/Section&gt;

            &lt;Section style={detailRow}&gt;
              &lt;Text style={detailLabel}&gt;Amount&lt;/Text&gt;
              &lt;Text style={detailValue}&gt;{formattedAmount}&lt;/Text&gt;
            &lt;/Section&gt;

            &lt;Section style={detailRow}&gt;
              &lt;Text style={detailLabel}&gt;Email&lt;/Text&gt;
              &lt;Text style={detailValue}&gt;{customerEmail}&lt;/Text&gt;
            &lt;/Section&gt;
          &lt;/Section&gt;

          &lt;Text style={text}&gt;
            This is a one-time purchase. No recurring charges will be made.
          &lt;/Text&gt;

          &lt;Hr style={divider} /&gt;

          &lt;Text style={footer}&gt;
            Questions about your purchase? Reply to this email or reach
            out at{" "}
            &lt;Link
              href={`mailto:${brand.emails.support}`}
              style={link}
            &gt;
              {brand.emails.support}
            &lt;/Link&gt;
          &lt;/Text&gt;
        &lt;/Container&gt;
      &lt;/Body&gt;
    &lt;/Html&gt;
  );
}

PurchaseConfirmationEmail.PreviewProps = {
  amount: 9900,
  currency: "usd",
  customerEmail: "customer@example.com",
} satisfies PurchaseConfirmationEmailProps;
</code></pre>
<p>A few things to note about this template.</p>
<ul>
<li><p><strong>Currency formatting happens in the template:</strong> The <code>amount</code> prop is in cents (the same format stored in your database and returned by Stripe). The <code>Intl.NumberFormat</code> call converts it to a human-readable string like "$99.00" and keeps currency formatting logic in one place.</p>
</li>
<li><p><strong>The</strong> <code>PreviewProps</code> <strong>object is for development.</strong> React Email uses these props to render a preview in the browser. The <code>satisfies</code> keyword ensures the preview props match the component's interface.</p>
</li>
<li><p><strong>All styles are inline objects.</strong> Email clients strip <code>&lt;style&gt;</code> tags and ignore most CSS. Inline styles are the only reliable way to style emails across Gmail, Outlook, Apple Mail, and every other client.</p>
</li>
</ul>
<h3 id="heading-how-to-build-a-repo-access-template">How to Build a Repo Access Template</h3>
<p>The repo access email is sent after the GitHub invitation succeeds:</p>
<pre><code class="language-tsx">// src/lib/email/emails/repo-access-granted.tsx
import {
  Body,
  Button,
  Container,
  Head,
  Heading,
  Hr,
  Html,
  Link,
  Preview,
  Section,
  Text,
} from "@react-email/components";

import { brand } from "@/lib/brand";

interface RepoAccessGrantedEmailProps {
  repoUrl: string;
}

export default function RepoAccessGrantedEmail({
  repoUrl,
}: RepoAccessGrantedEmailProps) {
  return (
    &lt;Html&gt;
      &lt;Head /&gt;
      &lt;Preview&gt;Your {brand.name} repository access is ready!&lt;/Preview&gt;
      &lt;Body style={main}&gt;
        &lt;Container style={container}&gt;
          &lt;Section style={header}&gt;
            &lt;Text style={logoText}&gt;{brand.name}&lt;/Text&gt;
          &lt;/Section&gt;

          &lt;Hr style={divider} /&gt;

          &lt;Heading style={h1}&gt;You are in!&lt;/Heading&gt;

          &lt;Text style={text}&gt;
            Your GitHub repository access has been granted. You now have
            full access to the {brand.name} codebase.
          &lt;/Text&gt;

          &lt;Section style={buttonContainer}&gt;
            &lt;Button style={button} href={repoUrl}&gt;
              Open Repository
            &lt;/Button&gt;
          &lt;/Section&gt;

          &lt;Section style={infoBox}&gt;
            &lt;Text style={infoTitle}&gt;Quick Start&lt;/Text&gt;
            &lt;Text style={infoText}&gt;
              &lt;strong&gt;1.&lt;/strong&gt; Clone the repository to your machine
            &lt;/Text&gt;
            &lt;Text style={infoText}&gt;
              &lt;strong&gt;2.&lt;/strong&gt; Run{" "}
              &lt;code style={codeStyle}&gt;bun install&lt;/code&gt; to install
              dependencies
            &lt;/Text&gt;
            &lt;Text style={infoText}&gt;
              &lt;strong&gt;3.&lt;/strong&gt; Follow the README for environment setup
            &lt;/Text&gt;
            &lt;Text style={infoText}&gt;
              &lt;strong&gt;4.&lt;/strong&gt; Run{" "}
              &lt;code style={codeStyle}&gt;bun dev&lt;/code&gt; to start building
            &lt;/Text&gt;
          &lt;/Section&gt;

          &lt;Hr style={divider} /&gt;

          &lt;Text style={footer}&gt;
            Need help? Reply to this email or reach out at{" "}
            &lt;Link
              href={`mailto:${brand.emails.support}`}
              style={link}
            &gt;
              {brand.emails.support}
            &lt;/Link&gt;
          &lt;/Text&gt;
        &lt;/Container&gt;
      &lt;/Body&gt;
    &lt;/Html&gt;
  );
}
</code></pre>
<p>This template includes a <code>&lt;Button&gt;</code> component that links directly to the GitHub repository. The quick start section gives the customer immediate next steps so they aren't left wondering what to do after gaining access.</p>
<h3 id="heading-how-to-build-an-abandoned-cart-template">How to Build an Abandoned Cart Template</h3>
<p>The abandoned cart email brings the customer back to your pricing page:</p>
<pre><code class="language-tsx">// src/lib/email/emails/abandoned-cart.tsx
import {
  Body,
  Button,
  Container,
  Head,
  Heading,
  Hr,
  Html,
  Preview,
  Section,
  Text,
} from "@react-email/components";

import { brand } from "@/lib/brand";

interface AbandonedCartEmailProps {
  customerEmail: string;
  checkoutUrl: string;
}

export default function AbandonedCartEmail({
  customerEmail,
  checkoutUrl,
}: AbandonedCartEmailProps) {
  return (
    &lt;Html&gt;
      &lt;Head /&gt;
      &lt;Preview&gt;Your {brand.name} checkout is waiting for you&lt;/Preview&gt;
      &lt;Body style={main}&gt;
        &lt;Container style={container}&gt;
          &lt;Section style={header}&gt;
            &lt;Text style={logoText}&gt;{brand.name}&lt;/Text&gt;
          &lt;/Section&gt;

          &lt;Hr style={divider} /&gt;

          &lt;Heading style={h1}&gt;You left something behind&lt;/Heading&gt;

          &lt;Text style={text}&gt;
            We noticed you started a checkout but did not complete your
            purchase. No worries. Your cart is still waiting for you.
          &lt;/Text&gt;

          &lt;Text style={text}&gt;
            {brand.name} gives you everything you need to ship your
            startup this weekend: authentication, payments, email,
            background jobs, and more. All wired together and ready
            to go.
          &lt;/Text&gt;

          &lt;Section style={buttonContainer}&gt;
            &lt;Button style={button} href={checkoutUrl}&gt;
              Complete Your Purchase
            &lt;/Button&gt;
          &lt;/Section&gt;

          &lt;Text style={textSmall}&gt;
            If you ran into any issues during checkout or have questions
            about {brand.name}, just reply to this email. I read every
            message personally.
          &lt;/Text&gt;

          &lt;Hr style={divider} /&gt;

          &lt;Text style={footer}&gt;
            This email was sent to {customerEmail} because you started
            a checkout on {brand.name}. If this was not you, you can
            safely ignore this email.
          &lt;/Text&gt;
        &lt;/Container&gt;
      &lt;/Body&gt;
    &lt;/Html&gt;
  );
}
</code></pre>
<p>The tone matters here. "You left something behind" is friendly, not pushy. The email explains the product's value briefly, includes a single clear call to action, and the footer explains why they received the email.</p>
<h3 id="heading-how-templates-integrate-with-durable-steps">How Templates Integrate with Durable Steps</h3>
<p>Every email template is invoked via <code>createElement</code> inside a <code>step.run()</code> block:</p>
<pre><code class="language-typescript">await step.run("send-purchase-confirmation", async () =&gt; {
  await sendEmail({
    to: user.email,
    subject: `Your ${brand.name} purchase is confirmed!`,
    template: createElement(PurchaseConfirmationEmail, {
      amount: purchase.amount,
      currency: purchase.currency,
      customerEmail: user.email,
    }),
  });
});
</code></pre>
<p>The <code>createElement</code> call creates a React element from the template component with the given props. The <code>sendEmail</code> function renders it to HTML via React Email's <code>render()</code> and sends it through Resend.</p>
<p>Because this is inside a <code>step.run()</code>, the email send is checkpointed. If Resend is down and the step fails, it retries on its own without re-running previous steps. The customer never gets a duplicate email.</p>
<h2 id="heading-how-to-test-the-complete-flow-locally">How to Test the Complete Flow Locally</h2>
<p>Testing the complete payment lifecycle locally requires three things running simultaneously: your application, the Stripe CLI forwarding webhook events, and the Inngest dev server processing background jobs.</p>
<h3 id="heading-step-1-start-the-stripe-cli">Step 1: Start the Stripe CLI</h3>
<p>Install the Stripe CLI and log in:</p>
<pre><code class="language-bash"># macOS
brew install stripe/stripe-cli/stripe

# Authenticate
stripe login
</code></pre>
<p>Forward webhook events to your local server:</p>
<pre><code class="language-bash">stripe listen --forward-to localhost:3000/api/payments/webhook
</code></pre>
<p>The CLI prints a webhook signing secret starting with <code>whsec_</code>. Copy this to your <code>.env</code> as <code>STRIPE_WEBHOOK_SECRET</code>.</p>
<h3 id="heading-step-2-start-the-inngest-dev-server">Step 2: Start the Inngest Dev Server</h3>
<p>The Inngest dev server gives you real-time visibility into every function execution, every step, and every retry:</p>
<pre><code class="language-bash">npx inngest-cli@latest dev -u http://localhost:3000/api/inngest
</code></pre>
<p>Open <code>http://localhost:8288</code> in your browser. This is the Inngest dashboard where you'll watch your durable functions execute step by step.</p>
<h3 id="heading-step-3-start-your-application">Step 3: Start Your Application</h3>
<pre><code class="language-bash">bun run dev
</code></pre>
<p>Your application should now be running on <code>http://localhost:3000</code>.</p>
<h3 id="heading-step-4-test-the-purchase-flow">Step 4: Test the Purchase Flow</h3>
<ol>
<li><p>Go to your pricing page and click the checkout button.</p>
</li>
<li><p>Use Stripe's test card number <code>4242 4242 4242 4242</code> with any future expiration date and any CVC.</p>
</li>
<li><p>Complete the checkout. Stripe redirects you to your success URL.</p>
</li>
<li><p>Your frontend calls the <code>/api/purchases/claim</code> endpoint with the session ID.</p>
</li>
<li><p>Watch the Inngest dashboard. You should see the <code>purchase-completed</code> function trigger and each step execute in sequence.</p>
</li>
</ol>
<p>In the Inngest dashboard, you will see:</p>
<ul>
<li><p><strong>Step 1:</strong> "lookup-user-and-purchase" completes with the user and purchase data.</p>
</li>
<li><p><strong>Step 2:</strong> "track-purchase-to-posthog" completes (or logs a warning if PostHog isn't configured).</p>
</li>
<li><p><strong>Step 3:</strong> "send-purchase-confirmation" completes. Check your email.</p>
</li>
<li><p><strong>Step 4:</strong> "send-admin-notification" completes (if <code>ADMIN_EMAIL</code> is set).</p>
</li>
<li><p><strong>Steps 5-9:</strong> Run if the user has a GitHub username linked.</p>
</li>
</ul>
<h3 id="heading-step-5-test-a-refund">Step 5: Test a Refund</h3>
<p>Trigger a refund through the Stripe CLI:</p>
<pre><code class="language-bash">stripe trigger charge.refunded
</code></pre>
<p>Or go to the Stripe dashboard, find the test payment, and issue a refund manually. The Stripe CLI will forward the <code>charge.refunded</code> webhook to your local server.</p>
<p>In the Inngest dashboard, you'll see the <code>refund-processed</code> function trigger with its own set of steps: lookup, conditional access revocation, status update, analytics tracking, and email notifications.</p>
<h3 id="heading-step-6-test-abandoned-cart-recovery">Step 6: Test Abandoned Cart Recovery</h3>
<p>Trigger a checkout expiration:</p>
<pre><code class="language-bash">stripe trigger checkout.session.expired
</code></pre>
<p>The <code>checkout-expired</code> function will appear in the Inngest dashboard. You'll see the 1-hour sleep step. In the dev server, you can fast-forward through sleeps by clicking the "Skip" button in the dashboard. This lets you test the delayed email without actually waiting an hour.</p>
<h3 id="heading-how-to-simulate-step-failures">How to Simulate Step Failures</h3>
<p>To test the retry behavior, temporarily throw an error in one of your steps:</p>
<pre><code class="language-typescript">const collaboratorResult = await step.run(
  "add-github-collaborator",
  async () =&gt; {
    throw new Error("Simulated GitHub API failure");
  }
);
</code></pre>
<p>In the Inngest dashboard, you'll see:</p>
<ul>
<li><p>Steps 1 through 4 succeed and their results are cached.</p>
</li>
<li><p>Step 5 fails and is retried with exponential backoff.</p>
</li>
<li><p>Steps 6 through 9 remain pending.</p>
</li>
</ul>
<p>Remove the thrown error, and on the next retry, step 5 succeeds. Steps 6 through 9 execute, while steps 1 through 4 aren't re-executed. This is the checkpointing behavior that makes durable execution reliable.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Building a complete SaaS payment flow is more than integrating Stripe Checkout. It's the entire lifecycle from "Buy" button to "Welcome" email, including the parts that happen when things go wrong.</p>
<p>Here's what you built in this tutorial:</p>
<ul>
<li><p>A <strong>database schema</strong> that tracks purchases through every state: completed, partially refunded, and fully refunded.</p>
</li>
<li><p>A <strong>Stripe product and price seed script</strong> that creates your catalog programmatically.</p>
</li>
<li><p>A <strong>checkout flow</strong> with session creation, payment verification, and idempotent purchase claiming.</p>
</li>
<li><p>A <strong>thin webhook handler</strong> that validates signatures and routes events to background jobs.</p>
</li>
<li><p>A <strong>9-step durable purchase function</strong> where each step is independently checkpointed and retried.</p>
</li>
<li><p>A <strong>refund handler</strong> that distinguishes between full and partial refunds, revoking access only when appropriate.</p>
</li>
<li><p>An <strong>abandoned cart recovery flow</strong> that waits an hour before sending a friendly recovery email.</p>
</li>
<li><p><strong>Three transactional email templates</strong> built with React Email: purchase confirmation, repo access granted, and abandoned cart.</p>
</li>
<li><p>A <strong>local testing setup</strong> with Stripe CLI, Inngest dev server, and step-by-step observability.</p>
</li>
</ul>
<p>The most important pattern is the separation between receiving and processing. Your API endpoints and webhook handlers should be thin: validate, record, enqueue, return. All the complex multi-step work happens in durable background functions where failures are isolated and retried at the step level.</p>
<p>This pattern scales. Add a new step to the purchase flow, and it gets the same checkpointing and retry behavior. Add a new webhook event, and you route it to a new durable function.</p>
<p>Your requirements may differ. You might sell subscriptions instead of one-time purchases, or provision API keys instead of GitHub access. The specific steps change, but the architecture stays the same.</p>
<p>If you want to start with all of these patterns already wired together in a production-ready codebase, <a href="https://eden-stack.com?utm_source=freecodecamp&amp;utm_medium=article&amp;utm_campaign=saas-payment-flow-stripe-webhooks-email">Eden Stack</a> includes the complete payment flow described in this article, along with 30+ additional production-tested patterns for authentication, email, analytics, background jobs, and more.</p>
<p><em>Magnus Rødseth builds AI-native applications and is the creator of</em> <a href="https://eden-stack.com?utm_source=freecodecamp&amp;utm_medium=article&amp;utm_campaign=saas-payment-flow-stripe-webhooks-email"><em>Eden Stack</em></a><em>, a production-ready starter kit with 30+ Claude skills encoding production patterns for AI-native SaaS development.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Handle Stripe Webhooks Reliably with Background Jobs ]]>
                </title>
                <description>
                    <![CDATA[ You've set up Stripe. Checkout works. Customers can pay. But what happens after payment? The webhook handler is where most payment integrations silently break. Your server crashes halfway through gran ]]>
                </description>
                <link>https://www.freecodecamp.org/news/stripe-webhooks-background-jobs/</link>
                <guid isPermaLink="false">69e8f14f5d1c10710571b1ae</guid>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Software Engineering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Magnus Rødseth ]]>
                </dc:creator>
                <pubDate>Wed, 22 Apr 2026 16:03:27 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/460d0b4c-c95d-4356-a6df-a0c0c52b78b6.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>You've set up Stripe. Checkout works. Customers can pay. But what happens <em>after</em> payment?</p>
<p>The webhook handler is where most payment integrations silently break. Your server crashes halfway through granting access. Your email service is down when you try to send the confirmation. Your database times out during a write.</p>
<p>Stripe retries the entire webhook, but your handler already sent the confirmation email before it crashed. Now the customer gets two emails and no access.</p>
<p>This article shows you how to fix this. You'll learn how to build webhook handlers that survive failures by splitting your post-payment logic into durable, independently retried steps. The pattern works for any multi-step webhook processing, not just Stripe.</p>
<p>Here's what you'll learn:</p>
<ul>
<li><p>Why Stripe webhooks fail silently in production</p>
</li>
<li><p>How a naïve inline handler breaks under real-world conditions</p>
</li>
<li><p>The pattern: webhook receives, validates, and enqueues (nothing more)</p>
</li>
<li><p>How to build a durable purchase flow with individually checkpointed steps</p>
</li>
<li><p>How to handle refunds and abandoned checkouts with the same pattern</p>
</li>
<li><p>How to test webhook handlers locally</p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should be familiar with:</p>
<ul>
<li><p>Node.js and TypeScript</p>
</li>
<li><p>Basic Stripe integration (checkout sessions, webhooks)</p>
</li>
<li><p>SQL databases (the examples use PostgreSQL with Drizzle ORM)</p>
</li>
<li><p>npm or any Node.js package manager</p>
</li>
</ul>
<p>You don't need prior experience with Inngest or durable execution. This article explains both from scratch.</p>
<h3 id="heading-what-you-need-to-install">What You Need to Install</h3>
<p>If you want to run the code examples, install these packages:</p>
<pre><code class="language-bash">npm install inngest stripe drizzle-orm @react-email/components resend
</code></pre>
<p>You'll also need the <a href="https://stripe.com/docs/stripe-cli">Stripe CLI</a> for local webhook testing. Install it via Homebrew on macOS (<code>brew install stripe/stripe-cli/stripe</code>) or follow the instructions in Stripe's documentation for other platforms.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-stripe-webhooks-fail-silently">Why Stripe Webhooks Fail Silently</a></p>
</li>
<li><p><a href="#heading-the-naive-approach-and-why-it-breaks">The Naïve Approach (and Why It Breaks)</a></p>
</li>
<li><p><a href="#heading-the-pattern-webhook-to-event-to-durable-function">The Pattern: Webhook to Event to Durable Function</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-the-webhook-endpoint">How to Set Up the Webhook Endpoint</a></p>
</li>
<li><p><a href="#heading-how-to-build-a-durable-purchase-flow">How to Build a Durable Purchase Flow</a></p>
</li>
<li><p><a href="#heading-how-to-handle-refunds-with-the-same-pattern">How to Handle Refunds with the Same Pattern</a></p>
</li>
<li><p><a href="#heading-how-to-recover-abandoned-checkouts">How to Recover Abandoned Checkouts</a></p>
</li>
<li><p><a href="#heading-how-to-test-webhook-handlers-locally">How to Test Webhook Handlers Locally</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-why-stripe-webhooks-fail-silently">Why Stripe Webhooks Fail Silently</h2>
<p>The happy path is easy. A customer pays, Stripe sends a <code>checkout.session.completed</code> event to your server, and your handler processes it. In development, this works every time.</p>
<p>Production is different: Your webhook handler typically needs to do several things after a successful payment. It looks up the user in the database, records the purchase, sends a confirmation email, notifies the admin, grants access to the product (maybe via a GitHub invitation or an API key), and schedules follow-up emails. That's five or six operations involving three or four external services.</p>
<p>Here are the failure modes that will eventually hit your webhook handler:</p>
<h4 id="heading-1-your-server-crashes-mid-processing">1. Your server crashes mid-processing</h4>
<p>The database write succeeded, but the email never sent. Stripe retries the webhook, and your handler runs again.</p>
<p>Now you have a duplicate database entry or a unique constraint error that kills the retry.</p>
<h4 id="heading-2-an-external-service-is-temporarily-down">2. An external service is temporarily down</h4>
<p>Your email provider returns a 500. Your GitHub API call gets rate-limited. Your analytics service times out.</p>
<p>The webhook handler throws, and Stripe retries the entire thing. But the steps that already succeeded (the database write, the first email) run again.</p>
<h4 id="heading-3-the-handler-times-out">3. The handler times out</h4>
<p>Stripe expects a 2xx response within about 20 seconds. If your handler does too much work, Stripe marks it as failed and retries. Your handler may have partially completed before the timeout.</p>
<h4 id="heading-4-partial-completion-with-no-rollback">4. Partial completion with no rollback</h4>
<p>This is the worst failure mode. Steps 1 through 3 succeed. Step 4 fails. Stripe retries, and steps 1 through 3 run again.</p>
<p>The customer gets two confirmation emails. The database gets a duplicate record. But step 4 still fails because the underlying issue (a rate limit, a service outage) hasn't been resolved.</p>
<h4 id="heading-5-race-conditions-on-retry">5. Race conditions on retry</h4>
<p>Stripe can deliver the same event more than once even without a failure on your end. Network glitches, load balancer timeouts, and Stripe's own retry logic mean your handler must be prepared for duplicate deliveries. If your handler isn't idempotent at every step, duplicates compound the partial-completion problem.</p>
<p>Stripe's retry behavior is well-designed. It uses exponential backoff and retries up to dozens of times over several days. But Stripe retries the <em>entire webhook delivery</em>.</p>
<p>It has no way to know that your handler completed steps 1 through 3 and only needs to retry step 4. That distinction is your responsibility.</p>
<p>The core problem is that your webhook handler does too many things in a single request. Every external call is a potential failure point, and you have no checkpointing between them. When one fails, you lose track of which ones already succeeded.</p>
<h2 id="heading-the-naive-approach-and-why-it-breaks">The Naïve Approach (and Why It Breaks)</h2>
<p>Here's what a typical webhook handler looks like. I've seen hundreds of variations of this pattern across codebases, tutorials, and Stack Overflow answers:</p>
<pre><code class="language-typescript">app.post("/api/payments/webhook", async (req, res) =&gt; {
  const event = stripe.webhooks.constructEvent(
    req.body,
    req.headers["stripe-signature"],
    process.env.STRIPE_WEBHOOK_SECRET
  );

  if (event.type === "checkout.session.completed") {
    const session = event.data.object;

    // Step 1: Look up the user
    const user = await db.users.findOne({ id: session.metadata.userId });

    // Step 2: Record the purchase
    await db.purchases.insert({
      userId: user.id,
      stripeSessionId: session.id,
      amount: session.amount_total,
      status: "completed",
    });

    // Step 3: Send confirmation email
    await sendEmail({
      to: user.email,
      subject: "Purchase confirmed!",
      template: "purchase-confirmation",
    });

    // Step 4: Grant product access (GitHub repo invitation)
    await addCollaborator(user.githubUsername);

    // Step 5: Send access email
    await sendEmail({
      to: user.email,
      subject: "Your repository access is ready!",
      template: "repo-access",
    });

    // Step 6: Track analytics
    await analytics.track(user.id, "purchase_completed", {
      amount: session.amount_total,
    });
  }

  res.json({ received: true });
});
</code></pre>
<p>This looks clean. It reads top-to-bottom. Every tutorial teaches it this way.</p>
<p>Now walk through what happens when step 4 fails. Maybe GitHub's API is rate-limited and the <code>addCollaborator</code> call throws an error. Your handler returns a 500 to Stripe.</p>
<p>Here is the state after the failure:</p>
<ul>
<li><p>The user exists in the database (step 1 was just a lookup, no problem).</p>
</li>
<li><p>A purchase record was created (step 2 succeeded).</p>
</li>
<li><p>The confirmation email was sent (step 3 succeeded).</p>
</li>
<li><p>GitHub access was <strong>not</strong> granted (step 4 failed).</p>
</li>
<li><p>The access email was <strong>not</strong> sent (step 5 never ran).</p>
</li>
<li><p>Analytics were <strong>not</strong> tracked (step 6 never ran).</p>
</li>
</ul>
<p>Stripe retries the webhook. Your handler runs again from the top:</p>
<ul>
<li><p>Step 1: Looks up the user again. Fine.</p>
</li>
<li><p>Step 2: Tries to insert another purchase record. If you have a unique constraint on <code>stripeSessionId</code>, this throws. If you don't, you now have a duplicate.</p>
</li>
<li><p>Step 3: Sends the confirmation email again. The customer gets a second "Purchase confirmed!" email.</p>
</li>
<li><p>Step 4: Tries GitHub access again. Maybe it works this time, maybe not.</p>
</li>
<li><p>Steps 5-6: May or may not run depending on step 4.</p>
</li>
</ul>
<p>You can patch this with idempotency checks: "if purchase already exists, skip step 2." But now your handler is full of conditional logic for every step. And you still have the duplicate email problem, because there's no way to check "did I already send this email?" without building your own tracking system.</p>
<p>This approach doesn't scale. Every new step adds another failure mode, another idempotency check, and another edge case.</p>
<h2 id="heading-the-pattern-webhook-to-event-to-durable-function">The Pattern: Webhook to Event to Durable Function</h2>
<p>The fix is a separation of concerns. Your webhook handler should do exactly one thing: validate the incoming event and enqueue it for processing. Nothing else.</p>
<p>All the actual work (database writes, emails, API calls, analytics) moves into a durable background function where each step is individually checkpointed, retried, and tracked.</p>
<p>Here's the flow:</p>
<pre><code class="language-text">Stripe webhook
    |
    v
Webhook endpoint (validate signature, extract event, enqueue)
    |
    v
Background job system (receives event)
    |
    v
Durable function
    |-- Step 1: Look up user and purchase (checkpointed)
    |-- Step 2: Track analytics (checkpointed)
    |-- Step 3: Send confirmation email (checkpointed)
    |-- Step 4: Send admin notification (checkpointed)
    |-- Step 5: Grant GitHub access (checkpointed)
    |-- Step 6: Track GitHub access (checkpointed)
    |-- Step 7: Update purchase record (checkpointed)
    |-- Step 8: Send repo access email (checkpointed)
    |-- Step 9: Schedule follow-up sequence (checkpointed)
</code></pre>
<p>Each step wrapped in <code>step.run()</code> is a durable checkpoint. If step 5 fails:</p>
<ul>
<li><p>Steps 1 through 4 do <strong>not</strong> re-run. Their results are cached.</p>
</li>
<li><p>Step 5 retries independently, with its own retry counter.</p>
</li>
<li><p>Once step 5 succeeds, steps 6 through 9 continue.</p>
</li>
</ul>
<p>This is what "durable execution" means. The function's progress survives failures. You get step-level retries instead of function-level retries. No duplicate emails. No duplicate database writes. No partial completion.</p>
<p>I use <a href="https://www.inngest.com/">Inngest</a> for this. It's an event-driven durable execution platform that provides step-level checkpointing out of the box. You define functions with <code>step.run()</code> blocks, and Inngest handles retry logic, state persistence, and observability. No Redis, no worker processes, no custom retry code.</p>
<p>Other tools can achieve similar results (Temporal, for example), but Inngest's developer experience with TypeScript is what sold me. You write normal async functions. The <code>step.run()</code> wrapper is the only addition.</p>
<h2 id="heading-how-to-set-up-the-webhook-endpoint">How to Set Up the Webhook Endpoint</h2>
<p>Your webhook endpoint should be minimal. Validate the signature, extract the event data, send it to your background job system, and return a 200 immediately.</p>
<p>Here's the real webhook endpoint from my production codebase:</p>
<pre><code class="language-typescript">import { constructWebhookEvent } from "@/lib/payments";
import { inngest } from "@/lib/jobs";

app.post("/api/payments/webhook", async ({ request, set }) =&gt; {
  const body = await request.text();
  const sig = request.headers.get("stripe-signature");

  if (!sig) {
    set.status = 400;
    return { error: "Missing signature" };
  }

  try {
    const event = await constructWebhookEvent(body, sig);
    console.log(`[Webhook] Received ${event.type}`);

    if (event.type === "charge.refunded") {
      const charge = event.data.object;
      await inngest.send({
        name: "stripe/charge.refunded",
        data: {
          chargeId: charge.id,
          paymentIntentId: charge.payment_intent,
          amountRefunded: charge.amount_refunded,
          originalAmount: charge.amount,
          currency: charge.currency,
        },
      });
    }

    if (event.type === "checkout.session.expired") {
      const session = event.data.object;
      await inngest.send({
        name: "stripe/checkout.session.expired",
        data: {
          sessionId: session.id,
          customerEmail: session.customer_email,
        },
      });
    }

    return { received: true };
  } catch (error) {
    console.error("[Webhook] Stripe verification failed:", error);
    set.status = 400;
    return { error: "Webhook verification failed" };
  }
});
</code></pre>
<p>Notice what this handler does <strong>not</strong> do: it does not look up users, write to the database, send emails, or call external APIs. It validates the Stripe signature, extracts the relevant fields, and sends a typed event to Inngest. The entire handler completes in milliseconds.</p>
<p>The <code>constructWebhookEvent</code> function wraps Stripe's signature verification:</p>
<pre><code class="language-typescript">import Stripe from "stripe";

export async function constructWebhookEvent(
  payload: string | Buffer,
  signature: string
) {
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
  if (!webhookSecret) {
    throw new Error("STRIPE_WEBHOOK_SECRET is not set");
  }
  const client = new Stripe(process.env.STRIPE_SECRET_KEY);
  return client.webhooks.constructEventAsync(payload, signature, webhookSecret);
}
</code></pre>
<p>One critical detail: you must pass the <strong>raw request body</strong> (as a string or buffer) to Stripe's signature verification. If your framework parses the body as JSON before you can access the raw string, the signature check will fail. This is the number one cause of "webhook signature verification failed" errors.</p>
<p>The Inngest client setup is minimal:</p>
<pre><code class="language-typescript">import { Inngest } from "inngest";

export const inngest = new Inngest({
  id: "my-app",
});
</code></pre>
<p>For the purchase flow specifically, a different endpoint sends the event (the "claim" route that the frontend calls after the customer returns from Stripe checkout). But the principle is identical: validate, enqueue, return.</p>
<pre><code class="language-typescript">// After verifying payment status with Stripe
await inngest.send({
  name: "purchase/completed",
  data: {
    userId: session.user.id,
    tier,
    sessionId,
  },
});
</code></pre>
<h2 id="heading-how-to-build-a-durable-purchase-flow">How to Build a Durable Purchase Flow</h2>
<p>This is the core of the article. The <code>handlePurchaseCompleted</code> function processes a purchase after payment using 9 individually checkpointed steps. Every step is real production code.</p>
<p>The example below grants access to a private GitHub repository because that's what this particular product sells.</p>
<p>Your product's "grant access" step will be different: upgrading a user to a Pro membership, provisioning API credits, unlocking a course, or activating a subscription. The durable step pattern is the same regardless of what you're delivering.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a694d8d4dc9b42434c218f/935ca377-52ff-4fc2-8e97-98fb7712c896.png" alt="Durable purchase flow with 9 numbered steps, showing step 5 failing and retrying while steps 1 through 4 remain checkpointed" style="display:block;margin:0 auto" width="5504" height="3072" loading="lazy">

<p>If step 5 fails (for example, the email provider is down), Inngest retries only step 5. Steps 1 through 4 are already checkpointed and don't re-execute. Steps 6 through 9 wait until step 5 succeeds.</p>
<pre><code class="language-typescript">import { eq } from "drizzle-orm";
import { createElement } from "react";

import { inngest } from "@/lib/jobs/client";
import { trackServerEvent } from "@/lib/analytics/server";
import { brand } from "@/lib/brand";
import { db, purchases, users } from "@/lib/db";
import {
  sendEmail,
  PurchaseConfirmationEmail,
  AdminPurchaseNotificationEmail,
  RepoAccessGrantedEmail,
} from "@/lib/email";
import { addCollaborator } from "@/lib/github";

export const handlePurchaseCompleted = inngest.createFunction(
  { id: "purchase-completed", triggers: [{ event: "purchase/completed" }] },
  async ({ event, step }) =&gt; {
    const { userId, tier, sessionId } = event.data;

    // Step 1: Look up user and purchase details
    const { user, purchase } = await step.run(
      "lookup-user-and-purchase",
      async () =&gt; {
        const userResult = await db
          .select({
            id: users.id,
            email: users.email,
            name: users.name,
            githubUsername: users.githubUsername,
          })
          .from(users)
          .where(eq(users.id, userId))
          .limit(1);

        const foundUser = userResult[0];
        if (!foundUser) {
          throw new Error(`User not found: ${userId}`);
        }

        const purchaseResult = await db
          .select({
            amount: purchases.amount,
            currency: purchases.currency,
            stripePaymentIntentId: purchases.stripePaymentIntentId,
          })
          .from(purchases)
          .where(eq(purchases.stripeCheckoutSessionId, sessionId))
          .limit(1);

        const foundPurchase = purchaseResult[0];

        return {
          user: foundUser,
          purchase: foundPurchase ?? {
            amount: 0,
            currency: "usd",
            stripePaymentIntentId: null,
          },
        };
      }
    );

    // Step 2: Track purchase completion in analytics
    await step.run("track-purchase-to-posthog", async () =&gt; {
      await trackServerEvent(userId, "purchase_completed_server", {
        tier,
        amount_cents: purchase.amount,
        currency: purchase.currency,
        stripe_session_id: sessionId,
      });
    });

    // Step 3: Send purchase confirmation to customer
    await step.run("send-purchase-confirmation", async () =&gt; {
      await sendEmail({
        to: user.email,
        subject: `Your purchase is confirmed!`,
        template: createElement(PurchaseConfirmationEmail, {
          amount: purchase.amount,
          currency: purchase.currency,
          customerEmail: user.email,
        }),
      });
    });

    // Step 4: Send admin notification
    await step.run("send-admin-notification", async () =&gt; {
      const adminEmail = process.env.ADMIN_EMAIL;
      if (!adminEmail) return;

      await sendEmail({
        to: adminEmail,
        subject: `New sale: ${user.email}`,
        template: createElement(AdminPurchaseNotificationEmail, {
          amount: purchase.amount,
          currency: purchase.currency,
          customerEmail: user.email,
          customerName: user.name,
          stripeSessionId: purchase.stripePaymentIntentId ?? sessionId,
        }),
      });
    });

    // Early return if user has no GitHub username
    if (!user.githubUsername) {
      return { success: true, userId, tier, githubAccessGranted: false };
    }

    // Step 5: Grant GitHub repository access
    const collaboratorResult = await step.run(
      "add-github-collaborator",
      async () =&gt; {
        return addCollaborator(user.githubUsername!);
      }
    );

    // Step 6: Track GitHub access granted
    await step.run("track-github-access", async () =&gt; {
      await trackServerEvent(userId, "github_access_granted", {
        tier,
        github_username: user.githubUsername,
        invitation_status: collaboratorResult.status,
      });
    });

    // Step 7: Update purchase record
    await step.run("update-purchase-record", async () =&gt; {
      await db
        .update(purchases)
        .set({
          githubAccessGranted: true,
          githubInvitationId: collaboratorResult.status,
          updatedAt: new Date(),
        })
        .where(eq(purchases.stripeCheckoutSessionId, sessionId));
    });

    // Step 8: Send repo access email
    await step.run("send-repo-access-email", async () =&gt; {
      await sendEmail({
        to: user.email,
        subject: `Your repository access is ready!`,
        template: createElement(RepoAccessGrantedEmail, {
          repoUrl: "https://github.com/your-org/your-repo",
        }),
      });
    });

    // Step 9: Schedule follow-up email sequence
    await step.run("schedule-follow-up", async () =&gt; {
      const purchaseRecord = await db
        .select({ id: purchases.id })
        .from(purchases)
        .where(eq(purchases.stripeCheckoutSessionId, sessionId))
        .limit(1);

      if (purchaseRecord[0]) {
        await inngest.send({
          name: "purchase/follow-up.scheduled",
          data: {
            userId,
            purchaseId: purchaseRecord[0].id,
            tier,
          },
        });
      }
    });

    return { success: true, userId, tier, githubAccessGranted: true };
  }
);
</code></pre>
<p>That's a lot of code. Let me walk through each step and explain why it's a separate checkpoint.</p>
<h3 id="heading-step-1-look-up-user-and-purchase">Step 1: Look Up User and Purchase</h3>
<pre><code class="language-typescript">const { user, purchase } = await step.run(
  "lookup-user-and-purchase",
  async () =&gt; {
    // ... database queries ...
    return { user: foundUser, purchase: foundPurchase };
  }
);
</code></pre>
<p>This step queries the database for the user and purchase records. If the database is temporarily unreachable, this step retries on its own.</p>
<p>The return value (<code>user</code> and <code>purchase</code>) is cached by Inngest. Every subsequent step can use <code>user.email</code>, <code>user.githubUsername</code>, and <code>purchase.amount</code> without re-querying the database.</p>
<p>If this step fails permanently (the user doesn't exist), it throws an error that halts the entire function. This is intentional. There's no point continuing if you can't find the user.</p>
<h3 id="heading-step-2-track-analytics">Step 2: Track Analytics</h3>
<pre><code class="language-typescript">await step.run("track-purchase-to-posthog", async () =&gt; {
  await trackServerEvent(userId, "purchase_completed_server", {
    tier,
    amount_cents: purchase.amount,
  });
});
</code></pre>
<p>Analytics tracking is a separate step because analytics services have their own failure modes (rate limits, outages, network timeouts). If PostHog is down, you don't want it to block the confirmation email.</p>
<p>In the production code, this step wraps the call in a try-catch so that a tracking failure doesn't halt the entire function. The analytics event is "nice to have," not critical.</p>
<h3 id="heading-step-3-send-purchase-confirmation-email">Step 3: Send Purchase Confirmation Email</h3>
<pre><code class="language-typescript">await step.run("send-purchase-confirmation", async () =&gt; {
  await sendEmail({
    to: user.email,
    subject: `Your purchase is confirmed!`,
    template: createElement(PurchaseConfirmationEmail, {
      amount: purchase.amount,
      currency: purchase.currency,
      customerEmail: user.email,
    }),
  });
});
</code></pre>
<p>This is the customer-facing confirmation. It's a separate step from the admin notification (step 4) because they're independent operations. If the admin email fails, the customer should still get their confirmation.</p>
<p>The <code>sendEmail</code> function uses Resend under the hood. If Resend returns a 500, this step retries. Because step 2 (analytics) already completed and is checkpointed, it won't re-run.</p>
<h3 id="heading-step-4-send-admin-notification">Step 4: Send Admin Notification</h3>
<pre><code class="language-typescript">await step.run("send-admin-notification", async () =&gt; {
  const adminEmail = process.env.ADMIN_EMAIL;
  if (!adminEmail) return;

  await sendEmail({
    to: adminEmail,
    subject: `New sale: ${user.email}`,
    template: createElement(AdminPurchaseNotificationEmail, { /* ... */ }),
  });
});
</code></pre>
<p>Admin notifications are completely independent from customer-facing operations. Separating them means a failure in one doesn't affect the other.</p>
<h3 id="heading-step-5-grant-github-access">Step 5: Grant GitHub Access</h3>
<pre><code class="language-typescript">const collaboratorResult = await step.run(
  "add-github-collaborator",
  async () =&gt; {
    return addCollaborator(user.githubUsername!);
  }
);
</code></pre>
<p>This is the step most likely to fail. GitHub's API has rate limits: it can time out, and the user's GitHub username might be invalid.</p>
<p>By making this its own step, a GitHub API failure doesn't trigger re-sends of the confirmation email (step 3) or the admin notification (step 4). Those steps are already checkpointed.</p>
<p>Notice the early return before this step: if the user has no GitHub username, the function returns early after step 4. The remaining steps only run when there's a GitHub account to grant access to.</p>
<h3 id="heading-step-6-track-github-access">Step 6: Track GitHub Access</h3>
<pre><code class="language-typescript">await step.run("track-github-access", async () =&gt; {
  await trackServerEvent(userId, "github_access_granted", {
    tier,
    github_username: user.githubUsername,
    invitation_status: collaboratorResult.status,
  });
});
</code></pre>
<p>This uses the <code>collaboratorResult</code> from step 5. Because <code>step.run()</code> caches return values, <code>collaboratorResult.status</code> is available here even if the function was interrupted and resumed between steps 5 and 6.</p>
<h3 id="heading-step-7-update-purchase-record">Step 7: Update Purchase Record</h3>
<pre><code class="language-typescript">await step.run("update-purchase-record", async () =&gt; {
  await db
    .update(purchases)
    .set({
      githubAccessGranted: true,
      githubInvitationId: collaboratorResult.status,
      updatedAt: new Date(),
    })
    .where(eq(purchases.stripeCheckoutSessionId, sessionId));
});
</code></pre>
<p>The database update happens after GitHub access is confirmed. You only mark <code>githubAccessGranted: true</code> after the collaborator invitation actually succeeded.</p>
<p>If you updated the record before granting access and the GitHub step failed, your database would say access was granted when it was not.</p>
<h3 id="heading-step-8-send-repo-access-email">Step 8: Send Repo Access Email</h3>
<pre><code class="language-typescript">await step.run("send-repo-access-email", async () =&gt; {
  await sendEmail({
    to: user.email,
    subject: `Your repository access is ready!`,
    template: createElement(RepoAccessGrantedEmail, {
      repoUrl: "https://github.com/your-org/your-repo",
    }),
  });
});
</code></pre>
<p>This email only sends after the GitHub invitation is confirmed (step 5) and the database is updated (step 7). The ordering matters. You don't want to tell the customer "your access is ready" if the invitation hasn't been sent.</p>
<h3 id="heading-step-9-schedule-follow-up-sequence">Step 9: Schedule Follow-Up Sequence</h3>
<pre><code class="language-typescript">await step.run("schedule-follow-up", async () =&gt; {
  const purchaseRecord = await db
    .select({ id: purchases.id })
    .from(purchases)
    .where(eq(purchases.stripeCheckoutSessionId, sessionId))
    .limit(1);

  if (purchaseRecord[0]) {
    await inngest.send({
      name: "purchase/follow-up.scheduled",
      data: {
        userId,
        purchaseId: purchaseRecord[0].id,
        tier,
      },
    });
  }
});
</code></pre>
<p>The final step triggers a separate Inngest function that handles the follow-up email sequence (day 7 onboarding tips, day 14 feedback request, day 30 testimonial request). This is an event-driven chain: one function completes and triggers another.</p>
<p>The follow-up function uses <code>step.sleep()</code> to wait between emails:</p>
<pre><code class="language-typescript">export const handlePurchaseFollowUp = inngest.createFunction(
  {
    id: "purchase-follow-up",
    triggers: [{ event: "purchase/follow-up.scheduled" }],
    cancelOn: [
      {
        event: "purchase/follow-up.cancelled",
        match: "data.purchaseId",
      },
    ],
  },
  async ({ event, step }) =&gt; {
    const { userId, purchaseId } = event.data;

    await step.sleep("wait-7-days", "7d");

    await step.run("send-day-7-email", async () =&gt; {
      // Check eligibility (user exists, not unsubscribed, not refunded)
      // Send onboarding tips email
    });

    await step.sleep("wait-14-days", "7d");

    await step.run("send-day-14-email", async () =&gt; {
      // Send feedback request email
    });

    await step.sleep("wait-30-days", "16d");

    await step.run("send-day-30-email", async () =&gt; {
      // Send testimonial request email
    });
  }
);
</code></pre>
<p>Notice the <code>cancelOn</code> option. If the purchase is refunded, you can send a <code>purchase/follow-up.cancelled</code> event, and the entire follow-up sequence stops. No stale emails sent to customers who asked for a refund.</p>
<h3 id="heading-why-each-step-must-be-separate">Why Each Step Must Be Separate</h3>
<p>The rule is simple: <strong>any operation that calls an external service or could fail independently should be its own step.</strong></p>
<p>A database query is a step because the database can be temporarily unreachable. An email send is a step because the email provider can return a 500. A GitHub API call is a step because it can be rate-limited.</p>
<p>If two operations always succeed or fail together (they share a single external call), they can be in the same step. But when in doubt, make it a separate step. The overhead is negligible, and the reliability gain is significant.</p>
<h2 id="heading-how-to-handle-refunds-with-the-same-pattern">How to Handle Refunds with the Same Pattern</h2>
<p>The refund flow follows the exact same durable step pattern. This function lives in the same file as <code>handlePurchaseCompleted</code>, so it shares the same imports (plus <code>removeCollaborator</code> from <code>@/lib/github</code> and the refund-specific email templates). Here's the <code>handleRefund</code> function:</p>
<pre><code class="language-typescript">export const handleRefund = inngest.createFunction(
  { id: "refund-processed", triggers: [{ event: "stripe/charge.refunded" }] },
  async ({ event, step }) =&gt; {
    const {
      chargeId,
      paymentIntentId,
      amountRefunded,
      originalAmount,
      currency,
    } = event.data;

    const isFullRefund = amountRefunded &gt;= originalAmount;

    // Step 1: Look up the purchase and user
    const { user, purchase } = await step.run(
      "lookup-purchase-by-payment-intent",
      async () =&gt; {
        const purchaseResult = await db
          .select({
            id: purchases.id,
            userId: purchases.userId,
            stripePaymentIntentId: purchases.stripePaymentIntentId,
            githubAccessGranted: purchases.githubAccessGranted,
          })
          .from(purchases)
          .where(eq(purchases.stripePaymentIntentId, paymentIntentId))
          .limit(1);

        const foundPurchase = purchaseResult[0];
        if (!foundPurchase) {
          return { user: null, purchase: null };
        }

        const userResult = await db
          .select({
            id: users.id,
            email: users.email,
            name: users.name,
            githubUsername: users.githubUsername,
          })
          .from(users)
          .where(eq(users.id, foundPurchase.userId))
          .limit(1);

        return { user: userResult[0] ?? null, purchase: foundPurchase };
      }
    );

    if (!purchase || !user) {
      return { success: false, reason: "no_matching_purchase" };
    }

    let accessRevoked = false;

    // Step 2: Revoke GitHub access (only for full refunds)
    if (isFullRefund &amp;&amp; user.githubUsername &amp;&amp; purchase.githubAccessGranted) {
      const revokeResult = await step.run(
        "revoke-github-access",
        async () =&gt; {
          return removeCollaborator(user.githubUsername!);
        }
      );
      accessRevoked = revokeResult.success;
    }

    // Step 3: Update purchase status
    await step.run("update-purchase-status", async () =&gt; {
      if (isFullRefund) {
        await db
          .update(purchases)
          .set({
            status: "refunded",
            githubAccessGranted: false,
            updatedAt: new Date(),
          })
          .where(eq(purchases.id, purchase.id));
      } else {
        await db
          .update(purchases)
          .set({
            status: "partially_refunded",
            updatedAt: new Date(),
          })
          .where(eq(purchases.id, purchase.id));
      }
    });

    // Step 4: Track refund in analytics
    await step.run("track-refund-event", async () =&gt; {
      await trackServerEvent(user.id, "refund_processed", {
        charge_id: chargeId,
        amount_cents: amountRefunded,
        original_amount_cents: originalAmount,
        currency,
        is_full_refund: isFullRefund,
        github_access_revoked: accessRevoked,
      });
    });

    // Step 5: Notify customer
    await step.run("send-customer-notification", async () =&gt; {
      if (isFullRefund) {
        await sendEmail({
          to: user.email,
          subject: "Your refund has been processed",
          template: createElement(AccessRevokedEmail, {
            customerEmail: user.email,
            refundAmount: amountRefunded,
            currency,
          }),
        });
      } else {
        await sendEmail({
          to: user.email,
          subject: "Your partial refund has been processed",
          template: createElement(PartialRefundEmail, {
            customerEmail: user.email,
            refundAmount: amountRefunded,
            originalAmount,
            currency,
          }),
        });
      }
    });

    // Step 6: Notify admin
    await step.run("send-admin-notification", async () =&gt; {
      const adminEmail = process.env.ADMIN_EMAIL;
      if (!adminEmail) return;

      await sendEmail({
        to: adminEmail,
        subject: `\({isFullRefund ? "Full" : "Partial"} refund: \){user.email}`,
        template: createElement(AdminRefundNotificationEmail, {
          customerEmail: user.email,
          customerName: user.name,
          githubUsername: user.githubUsername,
          refundAmount: amountRefunded,
          originalAmount,
          currency,
          stripeChargeId: chargeId,
          accessRevoked,
          isPartialRefund: !isFullRefund,
        }),
      });
    });

    return { success: true, accessRevoked, isFullRefund, userId: user.id };
  }
);
</code></pre>
<p>Three things are worth calling out in the refund flow.</p>
<ol>
<li><p><strong>Partial versus full refunds:</strong> The function distinguishes between the two using a simple comparison: <code>amountRefunded &gt;= originalAmount</code>. For a partial refund, the customer keeps access but the purchase status changes to <code>partially_refunded</code>. For a full refund, GitHub access is revoked and the status becomes <code>refunded</code>.  </p>
<p>This matters for your database integrity. Downstream systems (your dashboard, your analytics, your support tools) need accurate status values.</p>
</li>
<li><p><strong>Conditional step execution:</strong> The "revoke GitHub access" step only runs if three conditions are true: it's a full refund, the user has a GitHub username, and access was previously granted. Inngest handles this cleanly by skipping steps that don't need to run.  </p>
<p>This is more readable than deeply nested if-else blocks in a monolithic handler.</p>
</li>
<li><p><strong>Separate notifications for customers and admins:</strong> The customer gets a different email depending on whether the refund is full or partial. The admin always gets a detailed notification including the charge ID, the customer's GitHub username, and whether access was revoked.</p>
</li>
</ol>
<p>These are separate steps because a failure in the admin notification shouldn't block the customer notification. The customer's email is the higher priority.</p>
<h2 id="heading-how-to-recover-abandoned-checkouts">How to Recover Abandoned Checkouts</h2>
<p>Abandoned cart recovery is where the <code>step.sleep()</code> method shines. When a Stripe checkout session expires, you want to send a recovery email. But not immediately.</p>
<p>You want to wait an hour or so, giving the customer time to return on their own.</p>
<pre><code class="language-typescript">export const handleCheckoutExpired = inngest.createFunction(
  {
    id: "checkout-expired",
    triggers: [{ event: "stripe/checkout.session.expired" }],
  },
  async ({ event, step }) =&gt; {
    const { customerEmail, sessionId } = event.data;

    if (!customerEmail) {
      return { success: false, reason: "no_email" };
    }

    // Wait 1 hour before sending recovery email
    await step.sleep("wait-before-recovery-email", "1h");

    // Send abandoned cart email
    await step.run("send-abandoned-cart-email", async () =&gt; {
      const checkoutUrl = `https://yoursite.com/pricing`;

      await sendEmail({
        to: customerEmail,
        subject: "Your checkout is waiting",
        template: createElement(AbandonedCartEmail, {
          customerEmail,
          checkoutUrl,
        }),
      });
    });

    // Track the event
    await step.run("track-abandoned-cart", async () =&gt; {
      await trackServerEvent("anonymous", "abandoned_cart_email_sent", {
        customer_email: customerEmail,
        session_id: sessionId,
      });
    });

    return { success: true, customerEmail };
  }
);
</code></pre>
<p>The <code>step.sleep("wait-before-recovery-email", "1h")</code> line is the key. This pauses the function for one hour without consuming any compute resources.</p>
<p>Inngest handles the scheduling internally. After one hour, the function resumes and sends the email.</p>
<p>Without durable execution, you would need a cron job that queries a database for expired sessions, or a delayed job queue with Redis, or a <code>setTimeout</code> that gets lost when your server restarts. The <code>step.sleep()</code> approach is simpler, more readable, and more reliable.</p>
<p>There's also a guard at the top of the function. If Stripe doesn't have a customer email for the session (the customer closed the checkout before entering their email), the function returns early. There's no point scheduling a recovery email with no address to send it to.</p>
<p>This pattern scales to more complex recovery flows. You could add a second <code>step.sleep()</code> and send a follow-up recovery email three days later if the customer still hasn't purchased. You could check if the customer has since completed a purchase (by querying the database in a <code>step.run()</code>) and skip the email if they have.</p>
<p>Each additional step is one more <code>step.run()</code> or <code>step.sleep()</code> call. The function reads like a script describing your business logic, not a tangle of cron jobs and database flags.</p>
<h2 id="heading-how-to-test-webhook-handlers-locally">How to Test Webhook Handlers Locally</h2>
<p>Local testing is one of the biggest pain points with Stripe webhooks. You need Stripe to send events to your local machine, and you need your background job system running to process them. Here's the setup.</p>
<h3 id="heading-how-to-forward-stripe-events-locally">How to Forward Stripe Events Locally</h3>
<p>Install the <a href="https://stripe.com/docs/stripe-cli">Stripe CLI</a> and forward webhook events to your local server:</p>
<pre><code class="language-bash">stripe listen --forward-to localhost:3000/api/payments/webhook
</code></pre>
<p>The CLI prints a webhook signing secret (starting with <code>whsec_</code>). Set this as your <code>STRIPE_WEBHOOK_SECRET</code> environment variable for local development.</p>
<p>You can trigger test events directly:</p>
<pre><code class="language-bash">stripe trigger checkout.session.completed
stripe trigger charge.refunded
stripe trigger checkout.session.expired
</code></pre>
<h3 id="heading-how-to-run-the-inngest-dev-server">How to Run the Inngest Dev Server</h3>
<p>Inngest provides a local dev server that shows you every function execution, every step, and every retry in real time:</p>
<pre><code class="language-bash">npx inngest-cli@latest dev -u http://localhost:3000/api/inngest
</code></pre>
<p>The <code>-u</code> flag tells the Inngest dev server where your application is running so it can discover your functions. Open <code>http://localhost:8288</code> in your browser to see the Inngest dashboard.</p>
<h3 id="heading-how-to-watch-step-execution">How to Watch Step Execution</h3>
<p>The Inngest dev dashboard is where the durable execution pattern really clicks. When you trigger a Stripe event, you can see:</p>
<ol>
<li><p>The event arriving in the "Events" tab.</p>
</li>
<li><p>The function triggering in the "Runs" tab.</p>
</li>
<li><p>Each step executing one by one, with its input, output, and duration.</p>
</li>
<li><p>If a step fails, you see the error and the retry attempt.</p>
</li>
</ol>
<p>This visibility is something you don't get with inline webhook handlers. When a customer reports "I paid but didn't get access," you can look up the function run in the Inngest dashboard and see exactly which step failed and why. That kind of observability is invaluable in production.</p>
<h3 id="heading-how-to-simulate-failures">How to Simulate Failures</h3>
<p>To test the retry behavior, you can intentionally make a step fail. For example, temporarily throw an error in the "add-github-collaborator" step:</p>
<pre><code class="language-typescript">const collaboratorResult = await step.run(
  "add-github-collaborator",
  async () =&gt; {
    throw new Error("Simulated GitHub API failure");
  }
);
</code></pre>
<p>In the Inngest dashboard, you'll see:</p>
<ul>
<li><p>Steps 1 through 4 succeed and their results are cached.</p>
</li>
<li><p>Step 5 fails and is retried according to the retry policy.</p>
</li>
<li><p>Steps 6 through 9 remain pending until step 5 succeeds.</p>
</li>
</ul>
<p>Remove the thrown error, and on the next retry, step 5 succeeds. Steps 6 through 9 then execute in sequence, while steps 1 through 4 aren't re-executed. This is the checkpoint behavior in action.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The pattern for reliable Stripe webhooks comes down to one principle: <strong>separate receiving from processing.</strong></p>
<p>Your webhook endpoint validates the Stripe signature and sends a typed event to a background job system. That's all it does. The processing happens in a durable function where each step is individually checkpointed and retried.</p>
<p>Here's what this gives you:</p>
<ul>
<li><p><strong>No duplicate emails:</strong> A step that already succeeded doesn't re-run.</p>
</li>
<li><p><strong>No partial state:</strong> If step 5 fails, steps 1 through 4 are preserved and step 5 retries independently.</p>
</li>
<li><p><strong>Full observability:</strong> You can see exactly which step failed and why, for every function run.</p>
</li>
<li><p><strong>Built-in delayed execution:</strong> <code>step.sleep()</code> handles recovery emails and follow-up sequences without cron jobs.</p>
</li>
<li><p><strong>Composable workflows:</strong> One function can trigger another via events, creating chains like purchase completion leading to a 30-day follow-up sequence.</p>
</li>
</ul>
<p>This pattern isn't limited to Stripe. Any multi-step webhook processing benefits from durable execution: GitHub webhooks that trigger CI pipelines, Resend webhooks that track email delivery, or calendar webhooks that sync across services.</p>
<p>The principle is the same: Validate. Enqueue. Process durably.</p>
<p>I've used this pattern in production for <a href="https://eden-stack.com?utm_source=freecodecamp&amp;utm_medium=article&amp;utm_campaign=stripe-webhooks-background-jobs">Eden Stack</a>, where the purchase flow handles everything from payment confirmation to GitHub repository access grants to multi-week email sequences. The 9-step purchase function has processed every payment without a single missed step or duplicate email.</p>
<p>If you're building a SaaS with Stripe, start with the webhook endpoint pattern from this article. Keep the endpoint thin and move the processing into durable steps. You'll save yourself from the 3 AM debugging session when a customer says "I paid but nothing happened."</p>
<p>If you want the complete Stripe webhook and Inngest integration pre-built with purchase flows, refund handling, and follow-up email sequences ready to go, <a href="https://eden-stack.com?utm_source=freecodecamp&amp;utm_medium=article&amp;utm_campaign=stripe-webhooks-background-jobs">Eden Stack</a> includes everything from this article alongside 30+ additional production-tested patterns.</p>
<p><em>Magnus Rodseth builds AI-native applications and is the creator of</em> <a href="https://eden-stack.com?utm_source=freecodecamp&amp;utm_medium=article&amp;utm_campaign=stripe-webhooks-background-jobs"><em>Eden Stack</em></a><em>, a production-ready starter kit with 30+ Claude skills encoding production patterns for AI-native SaaS development.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Streamline Search in Web Applications with Elasticsearch  ]]>
                </title>
                <description>
                    <![CDATA[ They say data is the new gold. But navigating through a large dataset to meet the demands of consumers in record time still gives backend devs a headache. Conventional database queries often aren't to ]]>
                </description>
                <link>https://www.freecodecamp.org/news/streamline-search-functionality-in-web-apps-with-elasticsearch/</link>
                <guid isPermaLink="false">69e10d82b67a275a9d505023</guid>
                
                    <category>
                        <![CDATA[ elasticsearch ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ indexing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Search Engines ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwatobi ]]>
                </dc:creator>
                <pubDate>Thu, 16 Apr 2026 16:25:38 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/e6563d07-a253-4fd9-b1f6-54dc98a48319.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>They say data is the new gold. But navigating through a large dataset to meet the demands of consumers in record time still gives backend devs a headache.</p>
<p>Conventional database queries often aren't totally reliable in getting accurate search results fast. But fortunately, Elasticsearch comes to the rescue.</p>
<p>In this article, I'll walk you through how to use Elasticsearch to enhance database searches and analytics while still maintaining efficiency.</p>
<p>Here are the prerequisites for this tutorial:</p>
<ul>
<li><p>A Node.js environment</p>
</li>
<li><p>Basic backend knowledge</p>
</li>
</ul>
<p>With that, let's get started. But first of all, what is Elasticsearch?</p>
<h3 id="heading-table-of-content">Table of Content</h3>
<ul>
<li><p><a href="#heading-what-is-elasticsearch">What is Elasticsearch?</a></p>
</li>
<li><p><a href="#heading-elasticsearch-key-terms">Elasticsearch Key Terms</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-elasticsearch">How to Set Up Elasticsearch</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-the-demo-project">How to Set Up the Demo Project</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-elasticsearch-in-your-project">How to Set Up Elasticsearch in Your Project</a></p>
</li>
<li><p><a href="#heading-how-to-work-with-indexes-in-elasticsearch">How to Work with Indexes in Elasticsearch</a></p>
</li>
<li><p><a href="#heading-search-implementation">Search Implementation</a></p>
</li>
<li><p><a href="#heading-full-code">Full Code</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-is-elasticsearch">What is Elasticsearch?</h2>
<p>Elasticsearch is a search engine built by Apache that can index words and phrases, providing advanced text and vector search capabilities. It also has other useful features such as search analytics and an auto-complete feature.</p>
<p>Note that Elasticsearch isn't a database, even though it does provide indexing features (which popular databases also do).</p>
<p>Other popular alternatives to this tool used in production environments include <a href="https://www.algolia.com/">Algolia</a>, <a href="https://opensearch.org/">OpenSearch</a> and <a href="https://www.meilisearch.com/">MeiliSearch</a>.</p>
<h2 id="heading-elasticsearch-key-terms">Elasticsearch Key Terms</h2>
<p>in this section, we'll go over some important terminology used in Elasticsearch. To ease your understanding, I'll make references to common database terminologies.</p>
<ul>
<li><p><strong>Index</strong>: This serves as a storage location for the data you're going to explore. It's like the database for Elasticsearch. It also shares other properties that DBs possess like uniqueness.</p>
</li>
<li><p><strong>Document:</strong> This is the smallest unit of information stored within the index. It's structurally identical to the MongoDB-based document and is also similar to rows in SQL-based databases.</p>
</li>
<li><p><strong>Mapping:</strong> Mapping refers to sets of rules or instructions that define how documents and fields are stored in the Elasticsearch index.</p>
</li>
<li><p><strong>Score:</strong> This is generated by Elasticsearch to show the degree of relevance of the search query to the stored index.</p>
</li>
<li><p><strong>Analyzer:</strong> When data is sent to the Elasticsearch engine for indexing, it initially passes through an analyzer which processes the text before indexing. This is achieved via Filters and Tokenizers.</p>
</li>
<li><p><strong>Tokenizers:</strong> This tool converts the gross unstructured data sent to the Elasticsearch engine into structured data tokens for further processing and storage.</p>
</li>
<li><p><strong>Aggregator:</strong> This search tool performs detailed analysis on the tokens stored in the index to generate actionable data insights. It's an advantage of the Elasticsearch engine. Mongo DB’s aggregator offer similar functions.</p>
</li>
<li><p><strong>Filter</strong>: A set of instructions which modifies tokens generated during the process of analysis. This could entail removal of fillers, capitalization rules, and so on.</p>
</li>
<li><p><strong>Bulk index:</strong> This refers to indexing more than one document at once. You typically do this when indexing a database with pre-existing content.</p>
</li>
</ul>
<h2 id="heading-how-to-set-up-elasticsearch">How to Set Up Elasticsearch</h2>
<p>For the purpose of this tutorial, we'll use Elasticsearch's installable software on our local machine. Online hosted versions of Elasticsearch also exist which work hitch-free as well.</p>
<p><a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-elasticsearch-with-zip-on-windows">Here</a> is a link detailing how to setup Elasticsearch on Windows. For non-Windows users, you can also install Elasticsearch on <a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-elasticsearch-from-archive-on-linux-macos">Linux/Mac OS</a> or use <a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-elasticsearch-with-docker">Docker</a>.</p>
<p><strong>Note</strong> that for Windows users, make sure you run Elasticsearch as an Administrator to avoid installation errors.</p>
<p>After successful installation, you can test if it's functioning by navigating to <code>localhost:9200</code> which serves as the default local endpoint for Elasticsearch. There you'll see a success message on the screen similar to the image below:</p>
<img src="https://cdn.hashnode.com/uploads/covers/64bba6ecb09308034572f437/ad94560d-7629-45f1-a94d-eaed0b61cefe.png" alt="elastic search localhost homepage" style="display:block;margin:0 auto" width="813" height="744" loading="lazy">

<p>With that , we'll move on to setting up our project and integrating ElasticSearch into our demo project.</p>
<h2 id="heading-how-to-set-up-the-demo-project">How to Set Up the Demo Project</h2>
<p>For the sake of this tutorial, we will be utilizing a ready-built forum-based backend application built in Node Express JS. &nbsp;Here is the link to the project.</p>
<p>to get the project up and running, clone this package and run</p>
<p><code>npm start</code></p>
<p><code>MySQL</code> will serve as the default database for this tutorial. &nbsp;Let's now proceed to the next section.</p>
<h2 id="heading-how-to-set-up-elasticsearch-in-your-project">How to Set Up Elasticsearch in Your Project</h2>
<p>The existing demo project is a backend implementation of a forum site which allows users to post text content and facilitate discussions through category-based threads.</p>
<p>Elasticsearch is great for ensuring that users can sift through these posts and threads to accurately locate key content using distinct keywords. This is more effective than using traditional database search queries which can be cumbersome.</p>
<p>To set up Elasticsearch, start by installing the Elasticsearch <code>npm</code> package. To do this, run the command below in your project directory:</p>
<pre><code class="language-shell">npm install @elastic/elasticsearch
</code></pre>
<p>After successful installation, create a <code>config.js</code> file where you'll setup your driver to connect to your Elasticsearch application.</p>
<pre><code class="language-javascript">const { Client } = require('@elastic/elasticsearch');

const esClient = new Client({
  node: 'http://localhost:9200',
  auth: {
    username: process.env.ELASTICSEARCH_USERNAME,
    password: process.env.ELASTICSEARCH_PASSWORD
  },
  maxRetries: 5,
  requestTimeout: 60000,
  tls: {
    rejectUnauthorized: process.env.NODE_ENV !== 'development'
  }
});

module.exports = esClient;
</code></pre>
<p>To access and use Elasticsearch's capabilities within your backend application, you'll need to setup and configure your Elasticsearch driver. The details are specified in the config file code above.</p>
<p>As mentioned earlier, Elasticsearch runs on the <code>localhost:9200</code> port. So your Elasticsearch node will be directed to the localhost port. Online hosted Elasticsearch nodes will also work in similar scenarios.</p>
<p>Next in the config file, you'll provide the authentication credentials required to access Elasticsearch. The requested username and password will be supplied within the Auth object. If you're running Elasticsearch locally, authentication may not be required unless security is enabled.</p>
<p>In this scenario, <code>MaxRetries</code> refers to the number of maximum unsuccessful attempts to access Elasticsearch. In this case, we've pegged it at 5 attempts. <code>requestTimeout</code> is the time in milliseconds after which the request will automatically terminate if it's not processed.</p>
<p>Once you've completion the Config file, you'll import this config and initialize the Elasticsearch client when your backend starts.</p>
<h2 id="heading-how-to-work-with-indexes-in-elasticsearch">How to Work with Indexes in Elasticsearch</h2>
<p>Before we start harnessing the full power of Elasticsearch, we need to customize its search capabilities within the backend of the project. This involves setting up an index within the Elasticsearch Engine that indexes all posts made to the backend application.</p>
<pre><code class="language-javascript">const esClient = require('./config');

const setupIndex = async () =&gt; {
  try {
    const indexExists = await esClient.indices.exists({
      index: INDEX_NAME
    });

    if (indexExists) {
      console.log(`Index "${INDEX_NAME}" already exists`);
      return;
    }

    await esClient.indices.create({
      index: INDEX_NAME,
      ...indexMapping
    });

    console.log(`Index "${INDEX_NAME}" created`);
  } catch (err) {
    console.error(err);
    throw err;
  }
};
</code></pre>
<p>The code above highlights creating a new index. First, you need to invoke the <code>setupIndex()</code> function. Within this function, you're providing the preferred name for your index. &nbsp;Elasticsearch then checks if the name already exists within its indexes.</p>
<p>The function terminates if the index name already exists (to prevent duplication). But if it doesn't exist, it proceeds to create an index with that unique name alongside the index Mapping rules (which we'll discuss further shortly).</p>
<p>After creating the index, you'll see a success message in your application console.</p>
<h3 id="heading-how-to-delete-an-index">How to Delete an Index</h3>
<p>After a while, an index may no longer serve its purpose and you may need to remove it from Elasticsearch.</p>
<p>You can do this by executing the <code>esClient.indices.delete()</code> command as shown below:</p>
<pre><code class="language-javascript">const deleteIndex = async () =&gt; {
  try {
    await esClient.indices.delete({ index: INDEX_NAME });
    console.log(`${INDEX_NAME} deleted`);
  } catch (err) {
    console.error("Error deleting index:", err);
  }
};
</code></pre>
<h3 id="heading-how-to-delete-a-post-within-an-index">How to Delete a Post within an Index</h3>
<p>Sometimes, posts get deleted and modified. Also, users may get banned, after which you'd want to remove their content from the stored database .</p>
<p>In these cases, you'll want to ensure true deletion – that is, both from the database and from Elasticsearch indexed storage.</p>
<p>To do this, you'll call the <code>esClient.delete()</code> function, passing the Elasticsearch Client ID and the post's unique ID that you want to delete as callback arguments to your <code>esClient.delete</code> function.</p>
<pre><code class="language-javascript">const deletePost = async (postId) =&gt; {
  try {
    await esClient.delete({
      index: INDEX_NAME,
      id: postId.toString(),
    });

    console.log("Post successfully deleted");
    return { success: true, postId };
  } catch (err) {
    console.error(err);
    throw err;
  }
};
</code></pre>
<h3 id="heading-how-to-index-a-post">How to Index a Post</h3>
<p>After setting up the Elasticsearch Index, you'll want to automatically index posts made to the database into the Elasticsearch index.</p>
<p>To do this, you'll need to make sure that the post is compatible with your index schema via the <code>transformPostTOESRepo</code> function. This function extracts and formats the post data so it matches the Elasticsearch document structure.</p>
<pre><code class="language-javascript">const transformPostToESDoc = (post) =&gt; {
  return {
  id: post.id,
  title: post.title,
  content: post.body,
  author: post.author,
  category: post.category,
  tags: post.tags,
  views: post.views || 0,
  published_at: post.created_at
};

const indexPost = async (postId) =&gt; {
  try {
    const postRepo = await getPostRepo();
    const post = await postRepo.findOne({ where: { id: postId } });

    if (!post) {
      throw new Error("Post not available");
    }

    const esDocument = transformPostToESDoc(post);

    await esClient.index({
      index: INDEX_NAME,
      id: post.id.toString(),
      document: esDocument
    });

    console.log("Post successfully indexed");
    return { success: true, postId };
  } catch (err) {
    console.error(err);
    throw err;
  }
};
</code></pre>
<p>The post to be indexed must have a unique ID. For ease of use, we used the unique post ID constraint that comes by default in regular databases. Optionally, you can also use UUID libraries to generate unique post IDs.</p>
<p>The Post information is then attached to the <code>esClient.index()</code> function as the document to be indexed. We also put appropriate error handling measures in place to prevent the app from crashing if the process is unsuccessful.</p>
<h3 id="heading-how-to-define-elastic-search-mapping-rules">How to Define Elastic Search Mapping Rules</h3>
<p>Elasticsearch mappings define how your data is stored and indexed. They specify the data type of each field and how text is analyzed for search.</p>
<p>In the example below, we'll define an index configuration that includes custom analyzers for autocomplete and mappings for each post field (like title, content, and author).</p>
<pre><code class="language-javascript">const indexMapping = {
  settings: {
    analysis: {
      analyzer: {
        autocomplete: {
          type: 'custom',
          tokenizer: 'standard',
          filter: ['lowercase', 'autocomplete_filter']
        },
        autocomplete_search: {
          type: 'custom',
          tokenizer: 'standard',
          filter: ['lowercase']
        }
      },
      filter: {
        autocomplete_filter: {
          type: 'edge_ngram',
          min_gram: 2,
          max_gram: 10
        }
      }
    }
  },
  mappings: {
    properties: {
      id: { type: 'integer' },
      title: {
        type: 'text',
        analyzer: 'autocomplete',
        search_analyzer: 'autocomplete_search',
        fields: {
          keyword: { type: 'keyword' },
          standard: { type: 'text' }
        }
      },
      content: {
        type: 'text',
        analyzer: 'standard'
      },
      category: {
        type: 'keyword'
      },
      tags: { type: 'keyword' },
      author: {
        type: 'text',
        fields: {
          keyword: { type: 'keyword' }
        }
      },
      views: { type: 'integer' },
      published_at: { type: 'date' }
    }
  }
};
</code></pre>
<p>The <code>indexMapping</code> object defines how Elasticsearch should store and process your data. It consists of two main parts: <code>settings</code> and <code>mappings</code>.</p>
<p>The <code>mappings</code> section defines the structure of your documents. Each field (like <code>title</code>, <code>content</code>, or <code>author</code>) has a type such as <code>text</code>, <code>keyword</code>, <code>integer</code>, or <code>date</code>. This tells Elasticsearch how to store and search that field.</p>
<p>For text fields, we can also define analyzers. Analyzers control how text is broken into smaller pieces (tokens) during indexing and search.</p>
<p>In the <code>settings</code> section, we defined a custom analyzer for autocomplete. This uses an <code>edge_ngram</code> filter to generate partial word matches, so users can find results as they type. We also defined a separate <code>search_analyzer</code> to ensure that search queries are processed correctly.</p>
<p>Together, these settings allow you to support features like autocomplete while keeping search results accurate and efficient.</p>
<h2 id="heading-search-implementation">Search Implementation</h2>
<p>In order to implement your search functionality, you'll need to build out the API. This involves building the business logic service and the API route. You'll also use <code>GET</code> requests and attach your search term as a query. The result it generates will be received as a JSON document.</p>
<p>Then you'll implement the search post service function. In this scenario, you'll be using the search engine capabilities to search for phrases within the index. In line with best practices, you'll use a pagination technique to minimize&nbsp;receiving unwanted information.</p>
<p>The search query will consist of the index name, pagination parameters (<code>from</code> and <code>size</code>) to control which results are returned, and the expected maximum size of the result. &nbsp;You'll also attach a query object specifying the modality of the search that the Elasticsearch engine should use.</p>
<pre><code class="language-javascript">const searchElastic = async (query, page = 1, size = 10) =&gt; {
  const searchQuery = {
    index: INDEX_NAME,
    from: (page - 1) * size,
    size,
    query: {
      bool: {
        must: [
          {
            multi_match: {
              query,
              fields: ["title^3", "content"],
              type: "best_fields",
              fuzziness: "AUTO"
            }
          }
        ]
      }
    }
  };

  const result = await esClient.search(searchQuery);
  return result.hits.hits;
};
</code></pre>
<p>In the code above, the function is named <code>searchElastic</code>. The function contains three variables which must be passed in order to execute it: <code>size</code>, <code>page</code> and <code>query</code>.</p>
<p>The <code>size</code> variable specifies the maximum number of documents per search query to be returned. The default count could be any integer.</p>
<p>The query uses a <code>multi_match</code> clause to search across multiple fields, such as <code>title</code> and <code>content</code>. The <code>title^3</code> syntax boosts matches in the title, making them more relevant than matches in other fields.</p>
<p>We also included a <code>must</code> clause which defines conditions that documents must match to be included in the results.</p>
<p>The search results are usually ranked based on their degree of relevance to the search query.</p>
<h2 id="heading-full-code">Full Code</h2>
<p>With this, you've completed this tutorial and have configured Elasticsearch to index posts made to your database. Here's the full code:</p>
<ol>
<li>Elasticsearch Client (config.js):</li>
</ol>
<pre><code class="language-javascript">const { Client } = require('@elastic/elasticsearch');

const esClient = new Client({
  node: 'http://localhost:9200',
  auth: {
    username: process.env.ELASTICSEARCH_USERNAME,
    password: process.env.ELASTICSEARCH_PASSWORD
  },
  maxRetries: 5,
  requestTimeout: 60000,
  tls: {
    rejectUnauthorized: process.env.NODE_ENV !== 'development'
  }
});

module.exports = esClient;
</code></pre>
<ol>
<li>Index mapping:</li>
</ol>
<pre><code class="language-javascript">const indexMapping = {
  settings: {
    analysis: {
      analyzer: {
        autocomplete: {
          type: 'custom',
          tokenizer: 'standard',
          filter: ['lowercase', 'autocomplete_filter']
        },
        autocomplete_search: {
          type: 'custom',
          tokenizer: 'standard',
          filter: ['lowercase']
        }
      },
      filter: {
        autocomplete_filter: {
          type: 'edge_ngram',
          min_gram: 2,
          max_gram: 10
        }
      }
    }
  },
  mappings: {
    properties: {
      id: { type: 'integer' },
      title: {
        type: 'text',
        analyzer: 'autocomplete',
        search_analyzer: 'autocomplete_search',
        fields: {
          keyword: { type: 'keyword' },
          standard: { type: 'text' }
        }
      },
      content: {
        type: 'text',
        analyzer: 'standard'
      },
      category: {
        type: 'keyword'
      },
      tags: { type: 'keyword' },
      author: {
        type: 'text',
        fields: {
          keyword: { type: 'keyword' }
        }
      },
      views: { type: 'integer' },
      published_at: { type: 'date' }
    }
  }
};
</code></pre>
<ol>
<li>Create index:</li>
</ol>
<pre><code class="language-javascript">const setupIndex = async () =&gt; {
  try {
    const indexExists = await esClient.indices.exists({
      index: INDEX_NAME
    });

    if (indexExists) {
      console.log(`Index "${INDEX_NAME}" already exists`);
      return;
    }

    await esClient.indices.create({
      index: INDEX_NAME,
      ...indexMapping
    });

    console.log(`Index "${INDEX_NAME}" created`);
  } catch (err) {
    console.error(err);
    throw err;
  }
};
</code></pre>
<ol>
<li>Delete index:</li>
</ol>
<pre><code class="language-javascript">const deleteIndex = async () =&gt; {
  try {
    await esClient.indices.delete({ index: INDEX_NAME });
    console.log(`${INDEX_NAME} deleted`);
  } catch (err) {
    console.error("Error deleting index:", err);
  }
};
</code></pre>
<ol>
<li>Delete document (post):</li>
</ol>
<pre><code class="language-javascript">const deletePost = async (postId) =&gt; {
  try {
    await esClient.delete({
      index: INDEX_NAME,
      id: postId.toString()
    });

    console.log("Post successfully deleted");
    return { success: true, postId };
  } catch (err) {
    console.error(err);
    throw err;
  }
};
</code></pre>
<ol>
<li>Transform and index post:</li>
</ol>
<pre><code class="language-javascript">const transformPostToESDoc = (post) =&gt; {
  return {
  id: post.id,
  title: post.title,
  content: post.body,
  author: post.author,
  category: post.category,
  tags: post.tags,
  views: post.views || 0,
  published_at: post.created_at
};

const indexPost = async (postId) =&gt; {
  try {
    const postRepo = await getPostRepo();
    const post = await postRepo.findOne({ where: { id: postId } });

    if (!post) {
      throw new Error("Post not available");
    }

    const esDocument = transformPostToESDoc(post);

    await esClient.index({
      index: INDEX_NAME,
      id: post.id.toString(),
      document: esDocument
    });

    console.log("Post successfully indexed");
    return { success: true, postId };
  } catch (err) {
    console.error(err);
    throw err;
  }
};
</code></pre>
<ol>
<li>Search function:</li>
</ol>
<pre><code class="language-javascript">const searchElastic = async (query, page = 1, size = 10) =&gt; {
  const searchQuery = {
    index: INDEX_NAME,
    from: (page - 1) * size,
    size,
    query: {
      bool: {
        must: [
          {
            multi_match: {
              query,
              fields: ["title^3", "content"],
              type: "best_fields",
              fuzziness: "AUTO"
            }
          }
        ]
      }
    }
  };

  const result = await esClient.search(searchQuery);
  return result.hits.hits;
};
</code></pre>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>Now you know how to use Elasticsearch to improve user search in your web applications. Elasticsearch is agnostic which allows you to use it across programming languages and frameworks. Its large community base also provides helpful user guides to make onboarding easier.</p>
<p>To further harness Elasticsearch's power, you can explore other tools within the <strong>ELK</strong> stack (Elasticsearch, Log Stash, and Kibana ) that'll help you generate high quality data visualizations for your data, especially for enterprise applications.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>A fast and reliable search engine isn’t negotiable in your web applications these days. Elasticsearch is your go-to for getting this done.</p>
<p>If you would like to read other articles that will enhance your tech journey, feel free to check out <a href="https://portfolio-oluwatobi.netlify.app/">my website here</a> . Stay active!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Online Marketplace with Next.js, Express, and Stripe Connect ]]>
                </title>
                <description>
                    <![CDATA[ Have you ever wondered how platforms like Etsy, Uber, or Teachable handle payments for thousands of sellers? The answer is a multi-vendor marketplace: an application where merchants can sign up, list  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-online-marketplace-with-next-js-express-stripe-connect/</link>
                <guid isPermaLink="false">69d7ca9dfa7251682ec4b098</guid>
                
                    <category>
                        <![CDATA[ stripe ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Michael Okolo ]]>
                </dc:creator>
                <pubDate>Thu, 09 Apr 2026 15:49:49 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/1181805a-87ae-440d-9673-64efeb073aad.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Have you ever wondered how platforms like Etsy, Uber, or Teachable handle payments for thousands of sellers? The answer is a <strong>multi-vendor marketplace</strong>: an application where merchants can sign up, list products or services, and receive payments directly from customers.</p>
<p>In this handbook, you'll build a complete marketplace from scratch using TypeScript. You won't need a traditional database. Instead, you'll use Stripe as your product catalog and payment engine.</p>
<p>This is how many real-world marketplaces work: Stripe stores the products, prices, and customer data, while your application handles the user experience.</p>
<p>Here's what you'll build:</p>
<ol>
<li><p>A merchant onboarding flow where sellers create accounts and connect with Stripe</p>
</li>
<li><p>A product management system where merchants can add and list products directly through Stripe</p>
</li>
<li><p>A checkout flow that supports both one-time payments and recurring subscriptions</p>
</li>
<li><p>Webhooks that listen for payment events in real time</p>
</li>
<li><p>A billing portal where customers can manage their subscriptions</p>
</li>
<li><p>A complete storefront where customers can browse and buy products</p>
</li>
</ol>
<p>You can also grab the complete source code from the GitHub repository linked at the end.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-is-stripe-connect">What is Stripe Connect?</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-the-project">How to Set Up the Project</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-the-backend">How to Set Up the Backend</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-express-backend">How to Build the Express Backend</a></p>
</li>
<li><p><a href="#heading-how-to-handle-merchant-onboarding">How to Handle Merchant Onboarding</a></p>
<ul>
<li><p><a href="#heading-how-to-create-a-connected-account">How to Create a Connected Account</a></p>
</li>
<li><p><a href="#heading-how-to-create-the-onboarding-link">How to Create the Onboarding Link</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-check-account-status">How to Check Account Status</a></p>
</li>
<li><p><a href="#heading-how-to-create-products-through-stripe">How to Create Products Through Stripe</a></p>
</li>
<li><p><a href="#heading-how-to-fetch-products">How to Fetch Products</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-checkout-flow">How to Build the Checkout Flow</a></p>
</li>
<li><p><a href="#heading-how-to-handle-webhooks">How to Handle Webhooks</a></p>
</li>
<li><p><a href="#heading-how-to-configure-webhooks-in-the-stripe-dashboard">How to Configure Webhooks in the Stripe Dashboard</a></p>
</li>
<li><p><a href="#heading-how-to-test-webhooks-locally">How to Test Webhooks Locally</a></p>
</li>
<li><p><a href="#heading-how-to-add-the-billing-portal">How to Add the Billing Portal</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-nextjs-frontend">How to Build the Next.js Frontend</a></p>
</li>
<li><p><a href="#heading-how-to-create-the-account-context">How to Create the Account Context</a></p>
</li>
<li><p><a href="#heading-how-to-create-the-account-status-hook">How to Create the Account Status Hook</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-merchant-onboarding-component">How to Build the Merchant Onboarding Component</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-product-create-product-list-and-checkout">How to Build the Product Create, Product List and Checkout</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-product-form">How to Build the Product Form</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-main-page">How to Build the Main Page</a></p>
</li>
<li><p><a href="#heading-how-to-test-the-full-flow">How to Test the Full Flow</a></p>
</li>
<li><p><a href="#heading-how-the-payment-split-works">How the Payment Split Works</a></p>
</li>
<li><p><a href="#heading-next-steps">Next Steps</a></p>
</li>
<li><p><a href="#heading-acknowledgements">Acknowledgements</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>Before you begin, make sure you have the following:</p>
<ol>
<li><p>Node.js (version 18 or higher) installed on your machine</p>
</li>
<li><p>A basic understanding of React, TypeScript, and REST APIs</p>
</li>
<li><p>A Stripe account (sign up for free at <a href="http://stripe.com">stripe.com</a>)</p>
</li>
<li><p>A code editor like VS Code</p>
</li>
</ol>
<p>You do <strong>not</strong> need a database for this project. Stripe will store your products, prices, and customer information. This keeps the architecture simple and mirrors how many production marketplaces actually work.</p>
<h2 id="heading-what-is-stripe-connect"><strong>What is Stripe Connect?</strong></h2>
<p>Stripe Connect is a set of APIs designed for platforms and marketplaces. It lets you create accounts for your merchants (Stripe calls them "connected accounts"), route payments to them, and take a platform fee on every transaction.</p>
<p>In this tutorial, you will use Stripe’s <strong>V2 Accounts API</strong>, which is the newer and recommended way to create connected accounts. With the V2 API, you configure what each account can do (accept card payments, receive payouts) through a configuration object, and Stripe handles all compliance and identity verification through a hosted onboarding flow.</p>
<p>Here's how the payment flow works:</p>
<ol>
<li><p>A customer selects a product and clicks checkout on your marketplace.</p>
</li>
<li><p>Your server creates a Stripe Checkout Session linked to the merchant’s connected account.</p>
</li>
<li><p>The customer pays on Stripe’s hosted checkout page.</p>
</li>
<li><p>Stripe automatically splits the payment: the merchant gets their share, and your platform keeps an application fee.</p>
</li>
<li><p>Stripe sends a webhook event to your server confirming the payment.</p>
</li>
<li><p>The merchant can view their earnings and withdraw funds from their Stripe dashboard.</p>
</li>
</ol>
<h2 id="heading-how-to-set-up-the-project"><strong>How to Set Up the Project</strong></h2>
<p>Create a project folder with separate directories for your backend and frontend:</p>
<pre><code class="language-shell">mkdir marketplace &amp;&amp; cd marketplace
mkdir server client
</code></pre>
<h2 id="heading-how-to-set-up-the-backend"><strong>How to Set Up the Backend</strong></h2>
<p>Navigate into the server directory and initialize a TypeScript project:</p>
<pre><code class="language-shell">cd server
npm init -y
npm install express cors dotenv stripe
npm install -D typescript ts-node @types/express @types/cors @types/node
npx tsc --init
mkdir src
</code></pre>
<p>Open tsconfig.json and update it with these settings:</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"]
}
</code></pre>
<p>Then create a .env file in the server root:</p>
<pre><code class="language-plaintext">STRIPE_SECRET_KEY=sk_test_your_key_here
DOMAIN=http://localhost:3000
</code></pre>
<p>You can find your Stripe test secret key in the Stripe Dashboard under Developers &gt; API Keys. The DOMAIN variable tells your server where to redirect customers after checkout.</p>
<p>Add these scripts to your package.json:</p>
<pre><code class="language-json">{
&nbsp; "scripts": {
    "dev": "ts-node src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}
</code></pre>
<h2 id="heading-how-to-build-the-express-backend"><strong>How to Build the Express Backend</strong></h2>
<p>Create the file src/index.ts. This will be your entire backend. Let’s start with the setup and imports:</p>
<pre><code class="language-typescript">import express, { Request, Response, Router } from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import Stripe from 'stripe';

dotenv.config();

const app = express();
const router = Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);

app.use(cors({ origin: process.env.DOMAIN }));
app.use(express.static('public'));
</code></pre>
<p>Notice that we don't import any database client. Stripe is our data layer. Every product, price, customer, and transaction lives in Stripe. Your Express server is a thin orchestration layer that talks to the Stripe API on behalf of your frontend.</p>
<p>We also mount <code>express.static("public")</code> so you can serve static files later if needed. The webhook endpoint needs the raw request body, so we'll register it before the JSON parser. Let’s add that now.</p>
<h2 id="heading-how-to-handle-merchant-onboarding"><strong>How to Handle Merchant Onboarding</strong></h2>
<p>The first thing a merchant needs to do is create an account on your platform and connect it to Stripe. This involves two steps: creating a connected account, and then redirecting the merchant to Stripe’s hosted onboarding form.</p>
<h3 id="heading-how-to-create-a-connected-account">How to Create a Connected Account</h3>
<p>Add the following route to your src/index.ts:</p>
<pre><code class="language-typescript">// Type definitions for request bodies
interface CreateAccountBody {
  email: string;
}
interface AccountIdBody {
  accountId: string;
}

// Create a Connected Account using Stripe V2 API
router.post(
  '/create-connect-account',
  async (req: Request&lt;{}, {}, CreateAccountBody&gt;, res: Response) =&gt; {
    try {
      const account = await stripe.v2.core.accounts.create({
        display_name: req.body.email,
        contact_email: req.body.email,
        dashboard: 'full',
        defaults: {
          responsibilities: {
            fees_collector: 'stripe',
            losses_collector: 'stripe',
          },
        },
        identity: {
          country: 'GB',
          entity_type: 'company',
        },
        configuration: {
          customer: {},
          merchant: {
            capabilities: {
              card_payments: { requested: true },
            },
          },
        },
      });
      res.json({ accountId: account.id });
    } catch (error) {
      const message = error instanceof Error ? error.message : 'Unknown error';
      res.status(500).json({ error: message });
    }
  },
);
</code></pre>
<p>Let’s break down what this code does. The <code>stripe.v2.core.accounts.create()</code> method creates a new connected account using Stripe’s V2 API. Here are the key configuration options:</p>
<ol>
<li><p><code>dashboard: "full"</code> gives the merchant access to their own Stripe dashboard where they can view payments, manage payouts, and handle disputes.</p>
</li>
<li><p><code>responsibilities</code> tells Stripe who collects fees and who is liable for losses. Setting both to "stripe" means Stripe handles this, which is the simplest configuration.</p>
</li>
<li><p><code>identity</code> sets the country and entity type. Change "GB" to your merchants’ country code (for example, "US" for the United States).</p>
</li>
<li><p><code>configuration.merchant.capabilities</code> requests the <code>card_payments</code> capability, which lets the merchant accept credit card payments.</p>
</li>
</ol>
<h3 id="heading-how-to-create-the-onboarding-link">How to Create the Onboarding Link</h3>
<p>After creating the account, you need to redirect the merchant to Stripe’s hosted onboarding form. Add this route:</p>
<pre><code class="language-typescript">// Create Account Link for onboarding
router.post('/create-account-link', async (req: Request&lt;{}, {}, AccountIdBody&gt;, res: Response) =&gt; {
  const { accountId } = req.body;
  try {
    const accountLink = await stripe.v2.core.accountLinks.create({
      account: accountId,
      use_case: {
        type: 'account_onboarding',
        account_onboarding: {
          configurations: ['merchant', 'customer'],
          refresh_url: `${process.env.DOMAIN}`,
          return_url: `\({process.env.DOMAIN}?accountId=\){accountId}`,
        },
      },
    });
    res.json({ url: accountLink.url });
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Unknown error';
    res.status(500).json({ error: message });
  }
});
</code></pre>
<p>The <code>accountLinks.create()</code> method generates a temporary URL that takes the merchant to Stripe’s onboarding form. On that form, Stripe collects the merchant’s identity documents, bank account details, and tax information. You don't need to build any of this yourself.</p>
<p>The <code>return_url</code> is where Stripe redirects the merchant after they complete onboarding. Notice that you append the <code>accountId</code> as a query parameter so your frontend can pick it up and store it.</p>
<h2 id="heading-how-to-check-account-status"><strong>How to Check Account Status</strong></h2>
<p>You need a way to check whether a merchant has finished onboarding and is ready to accept payments. Add this route:</p>
<pre><code class="language-typescript">// Get Connected Account Status
router.get(
  '/account-status/:accountId',
  async (req: Request&lt;{ accountId: string }&gt;, res: Response) =&gt; {
    try {
      const account = await stripe.v2.core.accounts.retrieve(req.params.accountId, {
        include: ['requirements', 'configuration.merchant'],
      });
      const payoutsEnabled =
        account.configuration?.merchant?.capabilities?.stripe_balance?.payouts?.status === 'active';
      const chargesEnabled =
        account.configuration?.merchant?.capabilities?.card_payments?.status === 'active';
      const summaryStatus = account.requirements?.summary?.minimum_deadline?.status;
      const detailsSubmitted = !summaryStatus || summaryStatus === 'eventually_due';
      res.json({
        id: account.id,
        payoutsEnabled,
        chargesEnabled,
        detailsSubmitted,
        requirements: account.requirements?.entries,
      });
    } catch (error) {
      const message = error instanceof Error ? error.message : 'Unknown error';
      res.status(500).json({ error: message });
    }
  },
);
</code></pre>
<p>This route retrieves the connected account and checks three important statuses:</p>
<ul>
<li><p><code>chargesEnabled</code> tells you if the merchant can accept payments.</p>
</li>
<li><p><code>payoutsEnabled</code> tells you if they can receive payouts to their bank account.</p>
</li>
<li><p><code>detailsSubmitted</code> tells you if they have completed the onboarding form.</p>
</li>
</ul>
<p>Your frontend will use these flags to show or hide features.</p>
<h2 id="heading-how-to-create-products-through-stripe"><strong>How to Create Products Through Stripe</strong></h2>
<p>Instead of storing products in a database, you'll create them directly in Stripe. Each product is created on the merchant’s connected account using the <code>stripeAccount</code> header. This means each merchant has their own isolated product catalog inside Stripe.</p>
<pre><code class="language-typescript">// Type definition for product creation
interface CreateProductBody {
  productName: string;
  productDescription: string;
  productPrice: number;
  accountId: string;
}
// Create a product on the connected account
router.post('/create-product', async (req: Request&lt;{}, {}, CreateProductBody&gt;, res: Response) =&gt; {
  const { productName, productDescription, productPrice, accountId } = req.body;
  try {
    // Create the product on the connected account
    const product = await stripe.products.create(
      {
        name: productName,
        description: productDescription,
      },
      { stripeAccount: accountId },
    ); // Create a price for the product
    const price = await stripe.prices.create(
      {
        product: product.id,
        unit_amount: productPrice,
        currency: 'usd',
      },
      { stripeAccount: accountId },
    );
    res.json({
      productName,
      productDescription,
      productPrice,
      priceId: price.id,
    });
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Unknown error';
    res.status(500).json({ error: message });
  }
});
</code></pre>
<p>There are two Stripe API calls happening here. First, <code>stripe.products.create()</code> creates the product (name and description). Then <code>stripe.prices.create()</code> creates a price for that product (amount and currency).</p>
<p>Stripe separates products from prices because a single product can have multiple prices — for example, a monthly plan and an annual plan.</p>
<p>The <code>{ stripeAccount: accountId }</code> option on both calls tells Stripe to create these resources on the merchant’s connected account, not on your platform account. This is a critical detail: without it, the products would be created on your platform’s account and the merchant would never see them.</p>
<h2 id="heading-how-to-fetch-products"><strong>How to Fetch Products</strong></h2>
<p>Add a route to list all products for a given merchant:</p>
<pre><code class="language-typescript">// Fetch products for a specific account
router.get('/products/:accountId', async (req: Request&lt;{ accountId: string }&gt;, res: Response) =&gt; {
  const { accountId } = req.params;
  try {
    const options: Stripe.RequestOptions = {};
    if (accountId !== 'platform') {
      options.stripeAccount = accountId;
    }
    const prices = await stripe.prices.list(
      {
        expand: ['data.product'],
        active: true,
        limit: 100,
      },
      options,
    );
    const products = prices.data.map((price) =&gt; {
      const product = price.product as Stripe.Product;
      return {
        id: product.id,
        name: product.name,
        description: product.description,
        price: price.unit_amount,
        priceId: price.id,
        period: price.recurring ? price.recurring.interval : null,
      };
    });
    res.json(products);
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Unknown error';
    res.status(500).json({ error: message });
  }
});
</code></pre>
<p>This route fetches all active prices from a merchant’s Stripe account and expands the product data (using <code>expand: ["data.product"]</code>) so you get the product name and description in the same API call. The period field will be null for one-time products and "month" or "year" for subscriptions.</p>
<h2 id="heading-how-to-build-the-checkout-flow"><strong>How to Build the Checkout Flow</strong></h2>
<p>Your checkout flow needs to handle two scenarios: one-time payments for individual products, and recurring subscriptions. Stripe’s Checkout Sessions handle both — you just need to set the mode based on the price type.</p>
<pre><code class="language-typescript">// Type definition for checkout
interface CheckoutBody {
  priceId: string;
  accountId: string;
}
// Create checkout session
router.post(
  '/create-checkout-session',
  async (req: Request&lt;{}, {}, CheckoutBody&gt;, res: Response) =&gt; {
    const { priceId, accountId } = req.body;
    try {
      // Retrieve the price to determine if it is
      // one-time or recurring
      const price = await stripe.prices.retrieve(priceId, { stripeAccount: accountId });
      const isSubscription = price.type === 'recurring';
      const mode = isSubscription ? 'subscription' : 'payment';
      const session = await stripe.checkout.sessions.create(
        {
          line_items: [
            {
              price: priceId,
              quantity: 1,
            },
          ],
          mode,
          success_url: `${process.env.DOMAIN}/done?session_id={CHECKOUT_SESSION_ID}`,
          cancel_url: `${process.env.DOMAIN}`,
          ...(isSubscription
            ? {
                subscription_data: {
                  application_fee_percent: 10,
                },
              }
            : {
                payment_intent_data: {
                  application_fee_amount: 123,
                },
              }),
        },
        { stripeAccount: accountId },
      );
      res.redirect(303, session.url as string);
    } catch (error) {
      const message = error instanceof Error ? error.message : 'Unknown error';
      res.status(500).json({ error: message });
    }
  },
);
</code></pre>
<p>Here's what this route does step by step. First, it retrieves the price from the merchant’s connected account to check whether it is a one-time price or a recurring subscription. Then it creates a Checkout Session with the appropriate mode — either "payment" or "subscription".</p>
<p>The <code>application_fee_amount</code> is your platform’s cut of the transaction, specified in the smallest currency unit (cents for USD). In this example, you take $1.23 or 10% per transaction. For a real marketplace, you would likely calculate this as a percentage of the product price.</p>
<p>Notice that <code>application_fee_amount</code> goes inside <code>subscription_data</code> for subscriptions but inside <code>payment_intent_data</code> for one-time payments. This is a Stripe requirement — the two modes use different configuration objects.</p>
<p>Finally, the route uses <code>res.redirect(303, session.url)</code> to send the customer directly to Stripe’s hosted checkout page.</p>
<h2 id="heading-how-to-handle-webhooks"><strong>How to Handle Webhooks</strong></h2>
<p>Webhooks are how Stripe tells your server about events that happen asynchronously — like a successful payment, a failed charge, or a subscription cancellation.</p>
<p>In a production marketplace, you should <strong>never</strong> rely solely on redirect URLs to confirm payments. A customer might close their browser before the redirect completes. Webhooks are your source of truth.</p>
<p>Add the webhook endpoint <strong>before</strong> the JSON body parser. Stripe sends webhook payloads as raw bytes, and you need the raw body to verify the signature:</p>
<pre><code class="language-typescript">// IMPORTANT: Register this BEFORE app.use(express.json())
app.post(
  '/api/webhook',
  express.raw({ type: 'application/json' }),
  (req: Request, res: Response) =&gt; {
    let event: Stripe.Event = JSON.parse(req.body.toString()); // If you have an endpoint secret, verify the
    // signature for security
    const endpointSecret = process.env.WEBHOOK_SECRET;
    if (endpointSecret) {
      const signature = req.headers['stripe-signature'] as string;
      try {
        event = stripe.webhooks.constructEvent(req.body, signature, endpointSecret) as Stripe.Event;
      } catch (err) {
        const message = err instanceof Error ? err.message : 'Unknown error';
        console.log('Webhook signature verification failed:', message);
        res.sendStatus(400);
        return;
      }
    } // Handle the event
    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object as Stripe.Checkout.Session;
        console.log('Payment successful for session:', session.id); // Fulfill the order: send email, grant access,
        // update your records, and so on
        break;
      }
      case 'checkout.session.expired': {
        const session = event.data.object as Stripe.Checkout.Session;
        console.log('Session expired:', session.id); // Optionally notify the customer or clean up
        // any pending records
        break;
      }
      case 'checkout.session.async_payment_succeeded': {
        const session = event.data.object as Stripe.Checkout.Session;
        console.log('Delayed payment succeeded for session:', session.id); // Fulfill the order now that payment cleared
        break;
      }
      case 'checkout.session.async_payment_failed': {
        const session = event.data.object as Stripe.Checkout.Session;
        console.log('Payment failed for session:', session.id); // Notify the customer that payment failed
        break;
      }
      case 'customer.subscription.deleted': {
        const subscription = event.data.object as Stripe.Subscription;
        console.log('Subscription cancelled:', subscription.id); // Revoke access for the customer
        break;
      }
      default:
        console.log('Unhandled event type:', event.type);
    }
    res.send();
  },
);
</code></pre>
<p>The webhook handler checks for five key events.</p>
<ul>
<li><p><code>checkout.session.completed</code> fires when a payment succeeds — this is where you would fulfill an order, send a confirmation email, or grant access.</p>
</li>
<li><p><code>checkout.session.expired</code> fires when a session expires before the customer completes payment.</p>
</li>
<li><p><code>checkout.session.async_payment_succeeded</code> fires when a delayed payment method (like a bank transfer) finally goes through.</p>
</li>
<li><p><code>checkout.session.async_payment_failed</code> fires when a delayed payment method fails.</p>
</li>
<li><p>And <code>customer.subscription.deleted</code> fires when a subscription is cancelled.</p>
</li>
</ul>
<h2 id="heading-how-to-configure-webhooks-in-the-stripe-dashboard"><strong>How to Configure Webhooks in the Stripe Dashboard</strong></h2>
<p>Before you can receive webhook events, you need to tell Stripe where to send them and which events you care about. Follow these steps:</p>
<ol>
<li><p>Go to the Stripe Dashboard and navigate to Developers &gt; Webhooks.</p>
</li>
<li><p>Click "Add destination."</p>
</li>
<li><p>Under the account type, select "Connected and V2 accounts" since your payments go through connected merchant accounts.</p>
</li>
<li><p>Under "Events to listen for," click "All events" and select the following five events:</p>
<ul>
<li><p><code>checkout.session.async_payment_succeeded</code> — Occurs when a payment intent using a delayed payment method finally succeeds.</p>
</li>
<li><p><code>checkout.session.completed</code> — Occurs when a Checkout Session has been successfully completed.</p>
</li>
<li><p><code>checkout.session.expired</code> — Occurs when a Checkout Session expires before completion.</p>
</li>
<li><p><code>checkout.session.async_payment_failed</code> — Occurs when a payment intent using a delayed payment method fails.</p>
</li>
<li><p><code>customer.subscription.deleted</code> — Occurs whenever a customer’s subscription ends.</p>
</li>
</ul>
</li>
<li><p>Enter your webhook endpoint URL. For production, this would be something like <a href="https://yourdomain.com/api/webhook">https://yourdomain.com/api/webhook</a>. For local development, you will use the Stripe CLI instead (covered next).</p>
</li>
<li><p>Click "Add destination" to save.</p>
</li>
</ol>
<h2 id="heading-how-to-test-webhooks-locally"><strong>How to Test Webhooks Locally</strong></h2>
<p>For local development, you don't need to expose your server to the internet. Install the Stripe CLI and run:</p>
<pre><code class="language-shell">brew install stripe/stripe-cli/stripe
stripe login
stripe listen --forward-to localhost:4242/webhook
</code></pre>
<p>The CLI will print a webhook signing secret that starts with <code>whsec_</code>. Add this to your .env file as <code>WEBHOOK_SECRET</code>. The CLI intercepts all webhook events from Stripe and forwards them to your local server, so you can test the full payment flow without deploying anything.</p>
<h2 id="heading-how-to-add-the-billing-portal"><strong>How to Add the Billing Portal</strong></h2>
<p>The billing portal lets customers manage their subscriptions without you building any UI for it. Stripe hosts the entire experience — customers can update their payment method, change plans, or cancel their subscription.</p>
<pre><code class="language-typescript">// Create a billing portal session
router.post(
&nbsp; "/create-portal-session",
&nbsp; async (req: Request, res: Response) =&gt; {
&nbsp;&nbsp;&nbsp; const { session_id } = req.body as {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; session_id: string;
&nbsp;&nbsp;&nbsp; };
&nbsp;
&nbsp;&nbsp;&nbsp; try {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; const session =
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; await stripe.checkout.sessions.retrieve(
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; session_id
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; );
&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; const portalSession =
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; await stripe.billingPortal.sessions.create({
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; customer_account: session.customer_account as string,
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; return_url: `\({process.env.DOMAIN}?session_id=\){session_id}`,
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; });
&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; res.redirect(303, portalSession.url);
&nbsp;&nbsp;&nbsp; } catch (error) {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; const message =
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; error instanceof Error
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ? error.message
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; : "Unknown error";
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; res.status(500).json({ error: message });
&nbsp;&nbsp;&nbsp; }
&nbsp; }
);
</code></pre>
<p>This route takes a <code>session_id</code> from a previous checkout, retrieves the associated customer, and creates a billing portal session. The <code>customer_account</code> field links the portal to the correct connected account so the customer sees only their subscriptions with that specific merchant.</p>
<p>Now add the JSON parser and mount the router. This must come <strong>after</strong> the webhook route:</p>
<pre><code class="language-typescript">// JSON and URL-encoded parsers (AFTER webhook route)
app.use(express.urlencoded({ extended: true }));
app.use(express.json());

// Mount all routes under /api
app.use('/api', router);
const PORT: number = parseInt(process.env.PORT || '4242', 10);
app.listen(PORT, () =&gt; {
  console.log(`Server running on port ${PORT}`);
});
</code></pre>
<h2 id="heading-how-to-build-the-nextjs-frontend"><strong>How to Build the Next.js Frontend</strong></h2>
<p>Navigate to the client directory and create a new Next.js project with TypeScript:</p>
<pre><code class="language-shell">cd ../client
npx create-next-app@latest . --typescript --app --tailwind --eslint
npm install axios
</code></pre>
<h2 id="heading-how-to-create-the-account-context"><strong>How to Create the Account Context</strong></h2>
<p>You need a way to share the merchant’s account ID across all components. Create a context provider at <code>contexts/AccountContext.tsx</code>:</p>
<pre><code class="language-typescript">'use client';
import { createContext, useContext, useState, ReactNode } from 'react';
import { useSearchParams } from 'next/navigation';

interface AccountContextType {
  accountId: string | null;
  setAccountId: (id: string | null) =&gt; void;
}

const AccountContext = createContext&lt;AccountContextType | undefined&gt;(undefined);

export function useAccount(): AccountContextType {
  const context = useContext(AccountContext);
  if (!context) {
    throw new Error('useAccount must be used within AccountProvider');
  }
  return context;
}

export function AccountProvider({ children }: { children: ReactNode }) {
  const searchParams = useSearchParams();
  const [accountId, setAccountId] = useState&lt;string | null&gt;(searchParams.get('accountId'));

  return (
    &lt;AccountContext.Provider value={{ accountId, setAccountId }}&gt;
      {children}
    &lt;/AccountContext.Provider&gt;
  );
}
</code></pre>
<p>This context stores the current merchant’s account ID and makes it available throughout the app. On initial load, it checks the URL for an accountId query parameter — this is how Stripe’s onboarding redirect passes the account ID back to your app.</p>
<h2 id="heading-how-to-create-the-account-status-hook"><strong>How to Create the Account Status Hook</strong></h2>
<p>Create a custom hook at <code>hooks/useAccountStatus.ts</code> that polls the account status:</p>
<pre><code class="language-typescript">'use client';
import { useState, useEffect } from 'react';
import { useAccount } from '@/contexts/AccountContext';
interface AccountStatus {
  id: string;
  payoutsEnabled: boolean;
  chargesEnabled: boolean;
  detailsSubmitted: boolean;
}
export default function useAccountStatus() {
  const [accountStatus, setAccountStatus] = useState&lt;AccountStatus | null&gt;(null);
  const { accountId, setAccountId } = useAccount();
  useEffect(() =&gt; {
    if (!accountId) return;
    const fetchStatus = async () =&gt; {
      try {
        const res = await fetch(`http://localhost:4242/api/account-status/${accountId}`);
        if (!res.ok) throw new Error('Failed to fetch');
        const data: AccountStatus = await res.json();
        setAccountStatus(data);
      } catch {
        setAccountId(null);
      }
    };
    fetchStatus();
    const interval = setInterval(fetchStatus, 5000);
    return () =&gt; clearInterval(interval);
  }, [accountId, setAccountId]);
  return {
    accountStatus,
    needsOnboarding: !accountStatus?.chargesEnabled &amp;&amp; !accountStatus?.detailsSubmitted,
  };
}
</code></pre>
<p>This hook polls the account status every 5 seconds. This is important because Stripe’s onboarding is asynchronous — a merchant might complete the form, but it can take a moment for Stripe to verify their details and activate their account. The <code>needsOnboarding</code> flag tells your UI whether to show the onboarding button or the merchant dashboard.</p>
<h2 id="heading-how-to-build-the-merchant-onboarding-component"><strong>How to Build the Merchant Onboarding Component</strong></h2>
<p>Create <code>components/ConnectOnboarding.tsx</code>:</p>
<pre><code class="language-typescript">'use client';
import { useState } from 'react';
import { useAccount } from '@/contexts/AccountContext';
import useAccountStatus from '@/hooks/useAccountStatus';
const API_URL = 'http://localhost:4242/api';
export default function ConnectOnboarding() {
  const [email, setEmail] = useState&lt;string&gt;('');
  const { accountId, setAccountId } = useAccount();
  const { accountStatus, needsOnboarding } = useAccountStatus();
  const handleCreateAccount = async () =&gt; {
    const res = await fetch(`${API_URL}/create-connect-account`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email }),
    });
    const data = await res.json();
    setAccountId(data.accountId);
  };
  const handleStartOnboarding = async () =&gt; {
    const res = await fetch(`${API_URL}/create-account-link`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ accountId }),
    });
    const data = await res.json();
    window.location.href = data.url;
  };
  if (!accountId) {
    return (
      &lt;div className="max-w-md mx-auto p-6"&gt;
        &lt;h2 className="text-xl font-bold mb-4"&gt;Create Your Seller Account&lt;/h2&gt;
        &lt;input
          type="email"
          placeholder="Your email"
          value={email}
          onChange={(e) =&gt; setEmail(e.target.value)}
          className="w-full border p-2 rounded mb-4"
        /&gt;
        &lt;button
          onClick={handleCreateAccount}
          className="w-full bg-green-600 text-white p-2 rounded hover:bg-green-700"
        &gt;
          Create Connect Account
        &lt;/button&gt;
      &lt;/div&gt;
    );
  }
  return (
    &lt;div className="max-w-md mx-auto p-6"&gt;
      &lt;h3 className="font-semibold mb-2"&gt;Account: {accountId} &lt;/h3&gt;
      &lt;p className="mb-2"&gt;Charges: {accountStatus?.chargesEnabled ? 'Active' : 'Pending'} &lt;/p&gt;
      &lt;p className="mb-4"&gt;Payouts: {accountStatus?.payoutsEnabled ? 'Active' : 'Pending'} &lt;/p&gt;
      {needsOnboarding &amp;&amp; (
        &lt;button
          onClick={handleStartOnboarding}
          className="bg-purple-600 text-white px-6 py-2 rounded hover:bg-purple-700"
        &gt;
          Complete Onboarding
        &lt;/button&gt;
      )}
    &lt;/div&gt;
  );
}
</code></pre>
<p>This component handles both states of the merchant experience. If no account exists, it shows a simple email form. After account creation, it shows the account status and an onboarding button if needed.</p>
<h2 id="heading-how-to-build-the-product-create-product-list-and-checkout"><strong>How to Build the Product Create, Product List and Checkout</strong></h2>
<p>Create <code>components/Products.tsx</code>:</p>
<pre><code class="language-typescript">'use client';
import { useState, useEffect } from 'react';
import { useAccount } from '@/contexts/AccountContext';
import useAccountStatus from '@/hooks/useAccountStatus';
const API_URL = 'http://localhost:4242/api';
interface Product {
  id: string;
  name: string;
  description: string | null;
  price: number | null;
  priceId: string;
  period: string | null;
}
export default function Products() {
  const { accountId } = useAccount();
  const { needsOnboarding } = useAccountStatus();
  const [products, setProducts] = useState&lt;Product[]&gt;([]);
  useEffect(() =&gt; {
    if (!accountId || needsOnboarding) return;
    const fetchProducts = async () =&gt; {
      const res = await fetch(`\({API_URL}/products/\){accountId}`);
      const data: Product[] = await res.json();
      setProducts(data);
    };
    fetchProducts();
    const interval = setInterval(fetchProducts, 5000);
    return () =&gt; clearInterval(interval);
  }, [accountId, needsOnboarding]);
  return (
    &lt;div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-6"&gt;
      {' '}
      {products.map((product) =&gt; (
        &lt;div key={product.priceId} className="border rounded-lg p-4 shadow-sm"&gt;
          &lt;h3 className="text-lg font-semibold"&gt;&nbsp; {product.name}&lt;/h3&gt;

          &lt;p className="text-gray-600 mt-1"&gt;&nbsp; {product.description}&lt;/p&gt;

          &lt;p className="text-xl font-bold mt-3"&gt;
            ${((product.price ?? 0) / 100).toFixed(2)}
            {product.period ? ` / ${product.period}` : ''}
          &lt;/p&gt;

          &lt;form action={`${API_URL}/create-checkout-session`} method="POST"&gt;
            &lt;input type="hidden" name="priceId" value={product.priceId} /&gt;
            &lt;input type="hidden" name="accountId" value={accountId ?? ''} /&gt;
            &lt;button
              type="submit"
              className="mt-4 w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700"
            &gt;
              {product.period ? 'Subscribe' : 'Buy Now'}
            &lt;/button&gt;
          &lt;/form&gt;
        &lt;/div&gt;
      ))}
    &lt;/div&gt;
  );
}

</code></pre>
<p>The Products component fetches all products from the merchant’s Stripe account and displays them in a responsive grid. The checkout button submits a form directly to your backend, which redirects the customer to Stripe’s hosted checkout page. Notice how the button text changes based on whether the product is a one-time purchase or a subscription.</p>
<h2 id="heading-how-to-build-the-product-form"><strong>How to Build the Product Form</strong></h2>
<p>Merchants need a way to add products from the frontend. Create <code>components/ProductForm.tsx</code>:</p>
<pre><code class="language-typescript">'use client';
import { useState } from 'react';
import { useAccount } from '@/contexts/AccountContext';
import useAccountStatus from '@/hooks/useAccountStatus';
const API_URL = 'http://localhost:4242/api';
interface ProductFormData {
  productName: string;
  productDescription: string;
  productPrice: number;
}
export default function ProductForm() {
  const { accountId } = useAccount();
  const { needsOnboarding } = useAccountStatus();
  const [showForm, setShowForm] = useState&lt;boolean&gt;(false);
  const [formData, setFormData] = useState&lt;ProductFormData&gt;({
    productName: '',
    productDescription: '',
    productPrice: 1000,
  });
  const handleSubmit = async (e: React.FormEvent): Promise&lt;void&gt; =&gt; {
    e.preventDefault();
    if (!accountId || needsOnboarding) return;
    await fetch(`${API_URL}/create-product`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        ...formData,
        accountId,
      }),
    }); // Reset form and hide it
    setFormData({
      productName: '',
      productDescription: '',
      productPrice: 1000,
    });
    setShowForm(false);
  }; // Only show the form if the merchant has completed
  // onboarding and can accept charges
  if (!accountId || needsOnboarding) return null;
  return (
    &lt;div className="my-6"&gt;
      &lt;button
        onClick={() =&gt; setShowForm(!showForm)}
        className="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700"
      &gt;
        {showForm ? 'Cancel' : 'Add New Product'}
      &lt;/button&gt;

      {showForm &amp;&amp; (
        &lt;form onSubmit={handleSubmit} className="mt-4 max-w-md space-y-4"&gt;
          &lt;div&gt;
            &lt;label className="block text-sm font-medium mb-1"&gt;Product Name&lt;/label&gt;

            &lt;input
              type="text"
              value={formData.productName}
              onChange={(e) =&gt;
                setFormData({
                  ...formData,
                  productName: e.target.value,
                })
              }
              className="w-full border p-2 rounded"
              required
            /&gt;
          &lt;/div&gt;

          &lt;div&gt;
            &lt;label className="block text-sm font-medium mb-1"&gt;Description&lt;/label&gt;
            &lt;input
              type="text"
              value={formData.productDescription}
              onChange={(e) =&gt;
                setFormData({
                  ...formData,
                  productDescription: e.target.value,
                })
              }
              className="w-full border p-2 rounded"
            /&gt;
          &lt;/div&gt;
          &lt;div&gt;
            &lt;label className="block text-sm font-medium mb-1"&gt;Price (in cents)&lt;/label&gt;

            &lt;input
              type="number"
              value={formData.productPrice}
              onChange={(e) =&gt;
                setFormData({
                  ...formData,
                  productPrice: parseInt(e.target.value),
                })
              }
              className="w-full border p-2 rounded"
              required
            /&gt;
          &lt;/div&gt;
          &lt;button
            type="submit"
            className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
          &gt;
            Create Product
          &lt;/button&gt;
        &lt;/form&gt;
      )}
    &lt;/div&gt;
  );
}
</code></pre>
<p>This component only renders after the merchant has completed onboarding (the <code>if (!accountId || needsOnboarding) return null</code> check at the top). It toggles a form where the merchant enters a product name, description, and price in cents. When submitted, it calls your <code>/api/create-product</code> endpoint, which creates both the product and its price on the merchant’s connected Stripe account.</p>
<p>The price field uses cents because that is what Stripe expects. So if a merchant wants to sell a product for \(25.00, they enter 2500. In a production app, you would add a friendlier input that lets merchants type \)25.00 and converts it to cents automatically.</p>
<h2 id="heading-how-to-build-the-main-page"><strong>How to Build the Main Page</strong></h2>
<p>Finally, put it all together in <code>app/page.tsx</code>:</p>
<pre><code class="language-typescript">'use client';
import { AccountProvider } from '@/contexts/AccountContext';
import ConnectOnboarding from '@/components/ConnectOnboarding';
import Products from '@/components/Products';
import ProductForm from '@/components/ProductForm';
export default function Home() {
  return (
    &lt;AccountProvider&gt;
      {' '}
      &lt;main className="max-w-6xl mx-auto p-8"&gt;
        &lt;h1 className="text-3xl font-bold mb-8"&gt; Marketplace Dashboard &lt;/h1&gt;
        &lt;ConnectOnboarding /&gt;
        &lt;ProductForm /&gt;
        &lt;Products /&gt;
      &lt;/main&gt;
    &lt;/AccountProvider&gt;
  );
}
</code></pre>
<h2 id="heading-how-to-test-the-full-flow"><strong>How to Test the Full Flow</strong></h2>
<p>Start both servers:</p>
<pre><code class="language-shell"># Terminal 1 - Backend
cd server
npm run dev
&nbsp;
# Terminal 2 - Frontend
cd client
npm run dev
&nbsp;
# Terminal 3 - Stripe webhook listener
stripe listen --forward-to localhost:4242/api/webhook
</code></pre>
<p>Now test the complete flow:</p>
<ol>
<li><p>Go to <a href="http://localhost:3000">http://localhost:3000</a> and enter an email to create a merchant account.</p>
</li>
<li><p>Click "Complete Onboarding" and fill out Stripe’s test onboarding form. Use test data like 000-000-0000 for the phone number and 0000 for the last four digits of SSN.</p>
</li>
<li><p>Wait a few seconds for the account status to update. Once charges are active, you can add products.</p>
</li>
<li><p>Create a product using the product form (set the price in cents — for example, 2500 for $25.00).</p>
</li>
<li><p>Click "Buy Now" on a product to start the checkout flow.</p>
</li>
<li><p>On Stripe’s checkout page, use the test card number 4242 4242 4242 4242 with any future expiry date and any CVC.</p>
</li>
<li><p>Check your terminal — you should see the webhook event confirming the payment.</p>
</li>
<li><p>Check the Stripe Dashboard to see the payment, the application fee, and the transfer to the connected account.</p>
</li>
</ol>
<h2 id="heading-how-the-payment-split-works"><strong>How the Payment Split Works</strong></h2>
<p>Here is exactly what happens when a customer pays $25.00 for a product:</p>
<ol>
<li><p>The customer pays $25.00 on Stripe’s checkout page.</p>
</li>
<li><p>Stripe deducts its processing fee (approximately 2.9% + $0.30 for US cards).</p>
</li>
<li><p>Your platform takes the application fee you set ($1.23 in our example).</p>
</li>
<li><p>The remaining amount is transferred to the merchant’s connected Stripe account.</p>
</li>
<li><p>The merchant can withdraw their funds to their bank account from the Stripe Dashboard.</p>
</li>
</ol>
<p>You control the application fee in the checkout route. In a production marketplace, you would calculate this as a percentage of the transaction. For example, to take a 10% fee:</p>
<pre><code class="language-plaintext">onst applicationFee = Math.round(
&nbsp; (price.unit_amount ?? 0) * 0.1
);
</code></pre>
<h2 id="heading-next-steps"><strong>Next Steps</strong></h2>
<p>You now have a working marketplace. Here are improvements to consider for production:</p>
<ul>
<li><p>Add authentication with NextAuth.js so merchants can securely log in and manage their accounts across sessions.</p>
</li>
<li><p>Add runtime validation with Zod to validate all request bodies before they reach Stripe.</p>
</li>
<li><p>Add image uploads for products using Cloudinary or AWS S3, then pass the image URL to Stripe’s product metadata.</p>
</li>
<li><p>Build separate merchant and customer views. Right now the app combines both experiences on one page.</p>
</li>
<li><p>Deploy your backend to Railway or Render and your frontend to Vercel. Update the webhook URL in your Stripe Dashboard to point to your production server.</p>
</li>
</ul>
<p>You can find the complete source code for this tutorial on GitHub: <a href="https://github.com/michaelokolo/marketplace">https://github.com/michaelokolo/marketplace</a></p>
<h2 id="heading-acknowledgements"><strong>Acknowledgements</strong></h2>
<p>Some API usage patterns in this tutorial are inspired by examples from the <a href="https://docs.stripe.com">official Stripe documentation</a>. These examples were adapted to demonstrate how to build a complete multi-vendor marketplace architecture.</p>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>In this handbook, you built a complete online marketplace where merchants can onboard through Stripe Connect, create products stored directly in Stripe, and receive payments from customers — all without a traditional database.</p>
<p>You learned how to use Stripe’s V2 Accounts API for merchant onboarding, create products and prices on connected accounts, build a checkout flow that handles both one-time payments and subscriptions, listen for payment events with webhooks, and give customers a billing portal to manage their subscriptions.</p>
<p>The key insight is that Stripe Connect handles the hardest parts of running a marketplace — payment splitting, tax compliance, identity verification, and fund transfers. Your job is to build a great user experience on top of it.</p>
<p>If you found this tutorial helpful, share it with someone who is learning to build full-stack applications. Happy coding!</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
