<?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[ api - 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[ api - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sat, 22 Aug 2026 07:13:24 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/api/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Implement Privacy by Design in Modern APIs – A Developer's Practical Guide ]]>
                </title>
                <description>
                    <![CDATA[ As software developers, we're usually taught to prioritize features like speed, performance, and uptime. When we build APIs, our core concern is making sure data gets from Point A to Point B smoothly. ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-implement-privacy-by-design-in-modern-apis/</link>
                <guid isPermaLink="false">6a71e6ca56867ecf2f5d78c1</guid>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ APIs ]]>
                    </category>
                
                    <category>
                        <![CDATA[ privacy ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Design ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Developer Tools ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ samiatakande ]]>
                </dc:creator>
                <pubDate>Tue, 04 Aug 2026 13:19:06 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/2c0c5da0-cf2d-4414-9486-40be7a04f727.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>As software developers, we're usually taught to prioritize features like speed, performance, and uptime. When we build APIs, our core concern is making sure data gets from Point A to Point B smoothly.</p>
<p>But data privacy regulations are tightening globally, and users are growing increasingly conscious of their digital footprints. Treating privacy as a "legal afterthought" or something to fix later in production is no longer sustainable.</p>
<p>This is where <strong>Privacy by Design</strong> comes in.</p>
<p>Coined as a framework to integrate privacy proactively into the engineering lifecycle, Privacy by Design means your system architecture should naturally protect user data by default.</p>
<p>In this comprehensive guide, we'll walk through how to structurally build data privacy into your backend APIs using modern engineering patterns, code concepts, and intentional database designs.</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-the-principles-of-privacy-by-design-for-developers">The Principles of Privacy by Design for Developers</a></p>
</li>
<li><p><a href="#heading-project-directory-structure">Project Directory Structure</a></p>
</li>
<li><p><a href="#heading-implement-strict-data-minimization-at-the-endpoint-layer">Implement Strict Data Minimization at the Endpoint Layer</a></p>
</li>
<li><p><a href="#heading-decouple-pii-with-the-pseudonymization-token-pattern">Decouple PII with the Pseudonymization Token Pattern</a></p>
</li>
<li><p><a href="#heading-beyond-rbac-implementing-policy-based-access-control-pbac">Beyond RBAC: Implementing Policy-Based Access Control (PBAC)</a></p>
</li>
<li><p><a href="#heading-automate-data-retention-with-database-ttls-and-hooks">Automate Data Retention with Database TTLs and Hooks</a></p>
</li>
<li><p><a href="#heading-trust-is-the-ultimate-developer-metric">Trust is the Ultimate Developer Metric</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before diving into this tutorial, you should have the following:</p>
<ul>
<li><p>A foundational understanding of Node.js and JavaScript (ES6+).</p>
</li>
<li><p>Familiarity with building basic RESTful APIs using Express.</p>
</li>
<li><p>Essential knowledge of database interactions (SQL or NoSQL concepts).</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a5100d76df448adcc03e031/a423a8ed-9d57-4ab4-8617-d2ceb2fbfa31.png" alt="a423a8ed-9d57-4ab4-8617-d2ceb2fbfa31" style="display:block;margin:0 auto" width="542" height="610" loading="lazy"></li>
</ul>
<p><em>Figure 1: The four pillars of Privacy by Design for backend APIs.</em></p>
<blockquote>
<p><strong>Diagram Breakdown:</strong></p>
<ul>
<li><p><strong>Center:</strong> Core security shield representing privacy-first system design.</p>
</li>
<li><p><strong>Top-Left (Data Minimization):</strong> Payload filtering at the endpoint layer.</p>
</li>
<li><p><strong>Top-Right (Pseudonymization):</strong> Decoupling PII via tokenization.</p>
</li>
<li><p><strong>Bottom-Left (PBAC):</strong> Purpose-driven, context-aware authorization rules.</p>
</li>
<li><p><strong>Bottom-Right (Retention &amp; TTL):</strong> Automated data expiration via database hooks.</p>
</li>
</ul>
</blockquote>
<h2 id="heading-the-principles-of-privacy-by-design-for-developers">The Principles of Privacy by Design for Developers</h2>
<p>Before writing code, we need to shift our mindset. Privacy by Design isn't about writing a better "Privacy Policy" page on a website. It means translating structural abstractions into practical engineering boundaries.</p>
<p>For an API engineer, this boils down to four distinct execution pillars:</p>
<ul>
<li><p><strong>Proactive, not reactive:</strong> Preventing privacy data leaks before they happen rather than managing breaches after the fact.</p>
</li>
<li><p><strong>Privacy as the default:</strong> The user doesn't have to opt-in to remain private. The ecosystem protects them out of the box.</p>
</li>
<li><p><strong>End-to-end security:</strong> Data remains secure, structured, and minimized from ingestion to permanent deletion.</p>
</li>
<li><p><strong>Visibility and transparency:</strong> Keeping clean logs of what data is handled, where it lives, and why it is being used.</p>
</li>
</ul>
<p>Let's look at how we can implement these four foundations directly inside our codebases.</p>
<h2 id="heading-project-directory-structure">Project Directory Structure</h2>
<p>To give you a technical perspective of how these privacy patterns fit together cleanly inside a modular production application, we'll be referencing code across the following project layout:</p>
<pre><code class="language-text">api-privacy-design/
├── config/
│   └── database.js
├── middleware/
│   ├── auth.js
│   └── privacyPolicy.js
├── models/
│   ├── auditLog.js
│   └── user.js
├── services/
│   └── piiVault.js
├── validators/
│   └── userValidator.js
├── app.js
├── package.json
└── README.md
</code></pre>
<h2 id="heading-implement-strict-data-minimization-at-the-endpoint-layer">Implement Strict Data Minimization at the Endpoint Layer</h2>
<p>The fundamental rule of data privacy is simple: <strong>If you don’t have it, you can't lose it.</strong></p>
<h3 id="heading-the-payload-over-inclusion-anti-pattern">The Payload Over-Inclusion Anti-Pattern</h3>
<p>Many APIs accept massive, generic JSON payloads from frontend clients and save everything straight to the database. Developers often do this for convenience, using spread operators like <code>...req.body</code> to quickly insert records without explicitly mapping variables.</p>
<pre><code class="language-javascript">// A poorly designed registration endpoint that grabs everything indiscriminately
app.post('/api/register', async (req, res) =&gt; {
  const userData = req.body; // Accepts full profile, tracking tokens, internal metadata, etc.
  const user = await Database.save('users', userData);
  res.status(201).json(user);
});
</code></pre>
<p>If an attacker manipulates the client-side request to pass an administrative flag like <code>{"isAdmin": true, "internalDeviceID": "123"}</code> within the payload, a naïve endpoint will process it.</p>
<h3 id="heading-architectural-view-schema-validation">Architectural View: Schema Validation</h3>
<p>Data minimization means configuring endpoints to accept and store only what is strictly necessary for immediate business operations. To enforce this, explicit schema validation must occur right at the API gateway or controller boundary. This process ensures that only predefined, safe fields enter your internal system, and everything else is structurally ignored.</p>
<h3 id="heading-the-code-solution-schema-hardening">The Code Solution: Schema Hardening</h3>
<p>By implementing strict verification models (using validation libraries like Joi, Zod, or Yup), any unmapped field injected by a client is dropped or triggers a validation block.</p>
<p>Under our <code>validators/userValidator.js</code> configuration file, we can define our schema parameters.</p>
<p>If your validation provider is Joi-compatible, you can leverage native options like <code>{ stripUnknown: true }</code> to automatically clean incoming objects. If you choose to use an alternate validation framework like Zod, you can achieve an identical outcome by chaining the <code>.strict()</code> modifier to block requests that contain unmapped fields entirely.</p>
<p>Here's how we use Joi to drop non-explicit fields instantly at our router boundary:</p>
<pre><code class="language-javascript">const Joi = require('joi');

// Explicitly define the bare minimum schema required for validation
const registrationSchema = Joi.object({
  email: Joi.string().email().required(),
  password: Joi.string().min(8).required()
  // Any extra tracking metadata or unauthorized attributes injected here are dropped automatically
});

app.post('/api/register', async (req, res) =&gt; {
  try {
    // stripUnknown: true drops any properties not explicitly defined in the schema
    const validatedData = await registrationSchema.validateAsync(req.body, { stripUnknown: true });
    
    const user = await Database.save('users', validatedData);
    
    // Privacy Safeguard: Never return raw internal properties back to the client response
    res.status(201).json({ id: user.id, email: user.email }); 
  } catch (err) {
    res.status(400).json({ error: err.details[0].message });
  }
});
</code></pre>
<h2 id="heading-decouple-pii-with-the-pseudonymization-token-pattern">Decouple PII with the Pseudonymization Token Pattern</h2>
<p>When handling Personally Identifiable Information (PII) like real names, phone numbers, or physical home addresses, storing them in plain text inside your primary application tables is a massive security liability.</p>
<h3 id="heading-why-simple-encryption-isnt-enough">Why Simple Encryption Isn't Enough</h3>
<p>While encrypting columns at rest (using AES-256) is standard practice, application systems still face risks. If developers run analytical reporting, dump operational data into debugging environments, or accidentally output database rows to application logs (for example, <code>console.log(userObject)</code>), raw sensitive data can quickly spill across unauthorized environments.</p>
<h3 id="heading-the-tokenization-architecture">The Tokenization Architecture</h3>
<p>Instead of keeping PII alongside business tables, use a <strong>Pseudonymization Token Pattern</strong>. This separates identity from transactional records. This isn't just encryption anymore; it's an architectural separation of duties.</p>
<h3 id="heading-implementing-a-pseudonymized-data-flow">Implementing a Pseudonymized Data Flow</h3>
<p>Let's trace how the processing layer functions. In our isolated <code>services/piiVault.js</code> module, we manage the interaction between our primary app controller and our secure token storage vault. When a record initialisation occurs, we isolate the raw PII elements, exchange them for an opaque reference token, and use that reference token as our relational key.</p>
<pre><code class="language-javascript">// services/piiVault.js
// Conceptual abstraction of a decoupled user account initialization
async function createUserProfile(incomingPayload) {
  const { email, phoneNumber, ...transactionalData } = incomingPayload;

  // 1. Dispatch the raw PII elements directly to an isolated, encrypted Vault Service
  const vaultResponse = await PiiVaultService.tokenize({
    email: email,
    phone: phoneNumber
  });

  // 2. The Vault returns a uniquely structured reference UUID token (non-reversible string)
  const piiToken = vaultResponse.token; // e.g., "pii_token_83912x"

  // 3. Save the primary application record using ONLY the token link
  const operationalRecord = {
    ...transactionalData,
    piiRefToken: piiToken,
    accountStatus: 'active',
    createdAt: new Date()
  };

  await Database.save('primary_users_table', operationalRecord);
  return { success: true, reference: piiToken };
}
</code></pre>
<p>If internal developers run analytical operations or debug logs on the primary backend database, they'll only ever see non-identifiable reference strings rather than actual user credentials.</p>
<h2 id="heading-beyond-rbac-implementing-policy-based-access-control-pbac">Beyond RBAC: Implementing Policy-Based Access Control (PBAC)</h2>
<p>Traditional Role-Based Access Control (RBAC) (checking patterns like <code>if (user.role === 'admin')</code>) is no longer granular enough for complex software architectures. Privacy engineering demands that access control systems evaluate not just <em>who</em> is trying to view a resource, but the <em>purpose</em> of the access request.</p>
<h3 id="heading-the-core-vectors-of-pbac">The Core Vectors of PBAC</h3>
<p>By transitioning to Policy-Based Access Control (PBAC), your authorization system dynamically maps rules along three parameters:</p>
<ul>
<li><p><strong>The Actor/User:</strong> Who is making the API call and what are their active permissions?</p>
</li>
<li><p><strong>The Resource:</strong> What specific classification of data is being requested?</p>
</li>
<li><p><strong>The Purpose:</strong> Why does the business logic need to process this data, and did the data owner explicitly give consent for it?</p>
</li>
</ul>
<h3 id="heading-building-contextual-privacy-governance-middleware">Building Contextual Privacy Governance Middleware</h3>
<p>Let's see how this works in practice. To set the context for our route execution, we'll build a dedicated authorization middleware file under <code>middleware/privacyPolicy.js</code>. This middleware intercepts the incoming request vector, checks the destination resource owner's privacy choices, and matches it against the application's processing target context.</p>
<pre><code class="language-javascript">// middleware/privacyPolicy.js
// Middleware pattern verifying purpose-bound consent parameters
const verifyDataAccessPolicy = (requiredPurpose) =&gt; {
  return (req, res, next) =&gt; {
    const actor = req.user; // Instantiated from a previous authentication layer
    const resourceOwner = req.resourceOwner; // The target data record context
    
    // Extract the explicit consent array configured by the user
    const userConsentPolicies = resourceOwner.consents || []; // e.g., ['essential_auth', 'marketing_emails']

    // Check if the code execution purpose matches what the user explicitly consented to
    const hasExplicitConsent = userConsentPolicies.includes(requiredPurpose);
    
    // Elevate override permissions ONLY to specialized audit actors if explicitly tracked
    if (!hasExplicitConsent &amp;&amp; actor.role !== 'System_Auditor') {
      return res.status(403).json({ 
        error: "Access Denied: The operation requested exceeds your current purpose-bound user consent profiles." 
      });
    }
    
    // Access authorized; proceed down the middleware track
    next();
  };
};

// Application Execution Target Route within app.js
app.get('/api/analytics/user-behavior/:userId', 
  fetchResourceOwnerMiddleware, 
  verifyDataAccessPolicy('analytics_tracking'), // Will throw a 403 if user opted out of tracking
  (req, res) =&gt; {
    res.json({ status: "Success", data: "Contextual processing executed." });
  }
);
</code></pre>
<h2 id="heading-automate-data-retention-with-database-ttls-and-hooks">Automate Data Retention with Database TTLs and Hooks</h2>
<p>Data privacy frameworks emphasize that user data shouldn't sit inside database storage indefinitely. If a temporary log has fulfilled its purpose, or an account lifecycle ends, that data needs to disappear entirely.</p>
<h3 id="heading-the-automated-retention-lifecycle">The Automated Retention Lifecycle</h3>
<p>Relying on teams to run manual script updates or cron cleanup commands is prone to failure. Privacy-by-design systems build data expiration rules directly into their data models.</p>
<h3 id="heading-document-stores-native-ttl-indexes">Document Stores: Native TTL Indexes</h3>
<p>If you're managing unstructured payload layers or tracking sessions in NoSQL document collections (like MongoDB), you can leverage Time-To-Live (TTL) index configurations to wipe records automatically down to the second.</p>
<p>We write this logic directly into our schemas inside <code>models/auditLog.js</code>:</p>
<pre><code class="language-javascript">// models/auditLog.js
const mongoose = require('mongoose');

const auditLogSchema = new mongoose.Schema({
  userId: mongoose.Schema.Types.ObjectId,
  apiAction: String,
  ipAddress: String,
  createdAt: { 
    type: Date, 
    default: Date.now, 
    expires: '90d' // MongoDB background threads automatically drop this document after 90 days
  }
});

const AuditLog = mongoose.model('AuditLog', auditLogSchema);
</code></pre>
<h3 id="heading-relational-databases-safe-masking-with-orm-hooks">Relational Databases: Safe Masking with ORM Hooks</h3>
<p>In relational database systems (such as PostgreSQL or MySQL using Sequelize/Prisma), cascading relationships make immediate hard deletions tricky.</p>
<p>To avoid breaking database foreign keys while still respecting user privacy, we implement a two-step pattern: an instantaneous lifecycle hook handles data anonymisation and masking, while a native database scheduled script handles data cleansing asynchronously.</p>
<p>We can implement this hook inside our relational schema files under <code>models/user.js</code>:</p>
<pre><code class="language-javascript">// models/user.js
const { Sequelize, DataTypes } = require('sequelize');
const sequelize = new Sequelize('sqlite::memory:');

const OperationalUser = sequelize.define('OperationalUser', {
  username: DataTypes.STRING,
  email: DataTypes.STRING,
  fullName: DataTypes.STRING,
  isDeleted: { type: DataTypes.BOOLEAN, defaultValue: false }
});

// Anonymize records on the fly before a soft-deletion hook runs
OperationalUser.addHook('beforeUpdate', async (user, options) =&gt; {
  if (user.isDeleted &amp;&amp; user.changed('isDeleted')) {
    // Mask and overwrite confidential fields to protect privacy while keeping non-PII metrics intact
    user.email = `anonymized_user_${Date.now()}@privacy-protected.org`;
    user.fullName = "Anonymized Profile Data";
    user.username = `archived_node_${Math.floor(Math.random() * 100000)}`;
  }
});
</code></pre>
<h2 id="heading-trust-is-the-ultimate-developer-metric">Trust is the Ultimate Developer Metric</h2>
<p>Building high-throughput applications quickly is a fantastic skill, but engineering architectures that stand the test of time requires building for trust.</p>
<p>When you embed consent boundaries, strict payload validation schema controls, decoupled identifier vaults, and automated database retention routines into your backend services from day one, you aren't just checking boxes off a security compliance form. You're building reliable, loosely coupled code systems that mitigate data breach risks and treat user privacy as a first-class citizen in application design.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Serve a Multi-User AI Agent with FastAPI and Streamlit ]]>
                </title>
                <description>
                    <![CDATA[ In this tutorial, I’ll show you how to serve a multi-user local AI agent as a REST API using FastAPI, then add a lightweight Streamlit UI on top. Instead of interacting with the agent through a termin ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-serve-a-multi-user-ai-agent-with-fastapi-and-streamlit/</link>
                <guid isPermaLink="false">6a5e9c35892c69a16fdf27df</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ai-agent ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ollama ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #qwen ]]>
                    </category>
                
                    <category>
                        <![CDATA[ FastAPI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ streamlit ]]>
                    </category>
                
                    <category>
                        <![CDATA[ UI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ streaming ]]>
                    </category>
                
                    <category>
                        <![CDATA[ chatgpt ]]>
                    </category>
                
                    <category>
                        <![CDATA[ agentic ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Streaming API ]]>
                    </category>
                
                    <category>
                        <![CDATA[ langgraph ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Darsh Shah ]]>
                </dc:creator>
                <pubDate>Mon, 20 Jul 2026 22:07:49 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/e5bf4093-e618-4388-954c-f1a49bc87cfe.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this tutorial, I’ll show you how to serve a multi-user local AI agent as a REST API using FastAPI, then add a lightweight Streamlit UI on top.</p>
<p>Instead of interacting with the agent through a terminal, we’ll expose it over HTTP so multiple users can access it through a chat-style frontend interface. Each session will maintain its own conversation history and streamed responses.</p>
<p>The local AI agent will be built with LangChain v1, Ollama, Qwen, and Python, running on your own machine and ready to plug into larger applications without any per-call model API charges.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-background">Background</a></p>
</li>
<li><p><a href="#heading-what-is-fastapi">What is FastAPI</a>?</p>
</li>
<li><p><a href="#heading-what-is-streamlit">What is Streamlit</a>?</p>
</li>
<li><p><a href="#heading-what-is-multi-user-support">What Is Multi-User Support</a>?</p>
</li>
<li><p><a href="#heading-motivation-and-architecture">Motivation and Architecture</a></p>
</li>
<li><p><a href="#heading-step-1-install-ollama-and-pull-the-model">Step 1: Install Ollama and Pull the Model</a></p>
</li>
<li><p><a href="#heading-step-2-install-python-dependencies">Step 2: Install Python Dependencies</a></p>
</li>
<li><p><a href="#heading-step-3-build-the-agent-and-api-layer-with-fastapi">Step 3: Build the agent and API layer with FastAPI</a></p>
</li>
<li><p><a href="#heading-step-4-build-streamlit-ui">Step 4: Build Streamlit UI</a></p>
</li>
<li><p><a href="#heading-step-5-run-the-backend-app">Step 5: Run the backend app</a></p>
</li>
<li><p><a href="#heading-step-6-run-the-frontend-app">Step 6: Run the frontend app</a></p>
</li>
<li><p><a href="#heading-sample-output">Sample Output</a></p>
</li>
<li><p><a href="#heading-what-to-improve-before-production">What to Improve Before Production</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-background">Background</h2>
<p>Many AI agents start out as simple Python scripts that run in a command-line terminal. You type a message, the agent responds, and everything happens in a single local session.</p>
<p>That setup is great for development and testing, but it becomes limiting when you want other people or applications to interact with the agent.</p>
<p>To make an AI agent truly useful, we need to expose it through an interface that other users can access. A REST API is a practical way to do that.</p>
<p>To follow this tutorial, you'll need Ollama installed on your machine. The tutorial works on macOS, Windows, and Linux. I'm using a MacBook Pro with 32 GB of RAM, but you can run this on a lower-memory machine by choosing a smaller Qwen model from Ollama.</p>
<h2 id="heading-what-is-fastapi"><strong>What is FastAPI?</strong></h2>
<p><a href="https://github.com/fastapi/fastapi">FastAPI</a> is a Python web framework for building APIs. In this tutorial, it gives us a simple way to expose the agent over HTTP so other apps, scripts, or services can call it.</p>
<p>FastAPI is a good fit for AI apps because it gives us a clean boundary around the system. We define the request and response models in Python, FastAPI validates them automatically, and it turns HTTP requests into Python objects and Python objects back into JSON. It also generates interactive API docs for free and supports async endpoints, which is useful for AI workloads that may take longer to respond.</p>
<h2 id="heading-what-is-streamlit"><strong>What is Streamlit?</strong></h2>
<p><a href="https://streamlit.io">Streamlit</a> is a Python framework for building lightweight web interfaces with minimal frontend work. It lets us create interactive browser-based apps using normal Python code instead of HTML, CSS, and JavaScript.</p>
<p>In this tutorial, Streamlit sits on top of the FastAPI backend as a thin client. FastAPI exposes the AI agent over HTTP, and Streamlit gives us a simple UI for calling that API and displaying the results. That separation keeps the backend reusable while still making the agent easy to use in the browser.</p>
<h2 id="heading-what-is-multi-user-support"><strong>What Is Multi-User Support?</strong></h2>
<p>Multi-user support means the AI agent can handle requests from more than one user while keeping each user’s session separate.</p>
<p>For example, User 1&nbsp;asks the agent one question and User 2&nbsp;asks a different question. The agent should remember the correct context for each user independently. Without multi-user support, all users may end up sharing the same conversation state, which can lead to mixed responses, incorrect memory, or overwritten context.</p>
<h2 id="heading-motivation-and-architecture"><strong>Motivation and Architecture</strong></h2>
<p>Turning an AI agent into an API is the natural next step after building it locally. A Python script is great for experimenting, but an API makes the agent reusable. And adding multi-user support makes the agent extensible to be used by others.</p>
<p>To keep things simple, we’ll use a small local agent powered by Ollama and Qwen. The agent has two tools: one for checking the current time and another for counting words.</p>
<p>FastAPI provides the HTTP layer by exposing one endpoint called <code>/chat/stream</code>. When the request comes in with a user message, Pydantic validates the request, LangChain handles the agent loop and tool calling, and the final answer is returned as stream. Streamlit sits on top of that API and acts as a frontend that sends requests to the API and displays the results.</p>
<img src="https://cdn.hashnode.com/uploads/covers/684c95e159698b4bf6a0e4be/21a2b03d-b4c3-4211-82b1-aa265ac6fb1e.png" alt="image showing the sequence diagram of user calling the streamlit UI. The it goes to FastAPI layer, then to AI agent and finally Qwen and tool calls" style="display:block;margin:0 auto" width="1478" height="1000" loading="lazy">

<p>Example request:</p>
<pre><code class="language-json">{ 
    "message": "How many words are in: LangChain makes tool calling easier",
    "user_id":"123e4567-e89b-12d3-a456-426614174000"
 }
</code></pre>
<p>Example response:</p>
<pre><code class="language-json">{
  "answer": "There are **5** words in LangChain makes tool calling easier."
}
</code></pre>
<p>The model runs locally through Ollama, so there are no per-call model API charges.</p>
<h2 id="heading-step-1-install-ollama-and-pull-the-model"><strong>Step 1: Install Ollama and Pull the Model</strong></h2>
<p>To get started, install the Ollama application for your platform.</p>
<p>We’ll use Qwen as the chat model. I’m using <code>qwen3.5:4b</code>. If your machine has less RAM, you can use <code>qwen3.5:0.8b</code> instead.</p>
<pre><code class="language-plaintext">ollama pull qwen3.5:4b
</code></pre>
<h2 id="heading-step-2-install-python-dependencies"><strong>Step 2: Install Python Dependencies</strong></h2>
<p>Create a virtual environment and install the required packages:</p>
<pre><code class="language-plaintext">python3 -m venv venv
source venv/bin/activate

pip install fastapi uvicorn streamlit requests langchain langchain-core langchain-ollama langgraph
</code></pre>
<p>If tutorial requires LangChain &gt;= 1.0.0.</p>
<h2 id="heading-step-3-build-the-agent-and-api-layer-with-fastapi">Step 3: <strong>Build the Agent and API Layer with FastAPI</strong></h2>
<p>This application has three main responsibilities. FastAPI exposes the HTTP endpoint, Pydantic validates the incoming request data, and LangChain runs the agent, including tool calling and short-term memory.</p>
<p>The <code>user_id</code> sent with each request is used as the thread identifier, allowing the checkpointer to keep each user’s conversation history separate. This memory is per session. So every new session will have its own memory.</p>
<p>Another important detail is that the agent is created only once at startup with <code>agent = build_agent()</code>. Reusing the same agent instance avoids rebuilding the model and tool list for every request, which reduces overhead and improves response times while still supporting multiple users.</p>
<p>Inside the <code>/chat/stream</code> endpoint, the backend uses <a href="https://docs.langchain.com/oss/python/langchain/event-streaming">LangChain’s</a> <code>stream_events(..., version="v3")</code> to generate the response as a stream instead of waiting for the full answer all at once. FastAPI then wraps that stream in a <code>StreamingResponse</code>, so the frontend can receive the output gradually as it's produced. This makes the app feel much more interactive, because users can start reading the answer immediately while the rest is still being generated.</p>
<p>Put together, this gives you a lightweight backend that validates input, preserves separate memory for each user, and streams responses to the UI in real time.</p>
<p>Save the following code as <code>app.py</code>:</p>
<pre><code class="language-python">from datetime import datetime
from uuid import UUID

from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse

from pydantic import BaseModel

from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
from langgraph.checkpoint.memory import InMemorySaver

CHAT_MODEL = "qwen3.5:4b"

SYSTEM_PROMPT = (
    "You are a helpful assistant with access to tools for getting the current time "
    "and counting words in text. "
    "Use tools when needed. If the question does not need a tool, answer directly."
)

# -----------------------------
# Request model
# -----------------------------

class ChatRequest(BaseModel):
    user_id: UUID
    message: str

# -----------------------------
# Tools
# -----------------------------

@tool
def current_time() -&gt; str:
    """Return the current local date and time."""
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")


@tool
def word_count(text: str) -&gt; int:
    """Count the number of words in a piece of text."""
    return len(text.split())


# -----------------------------
# Agent + checkpoint memory
# -----------------------------

# Store conversation history in short term memory
checkpointer = InMemorySaver()

def build_agent():
    model = ChatOllama(model=CHAT_MODEL, temperature=0)
    return create_agent(
        model=model,
        tools=[current_time, word_count],
        system_prompt=SYSTEM_PROMPT,
        checkpointer=checkpointer,
    )


agent = build_agent()

# -----------------------------
# Streaming endpoint
# -----------------------------

app = FastAPI()

@app.post("/chat/stream")
def chat_stream(req: ChatRequest):
    def generate():
        run = agent.stream_events(
            {
                "messages": [{"role": "user", "content": req.message}],
            },
            config={
                "configurable": {
                    # Keep each user's short-term memory isolated
                    # by using their user_id as the thread ID.
                    "thread_id": str(req.user_id),
                }
            },
            version="v3",
        )

        for message in run.messages:
            for token in message.text:
                yield token

    return StreamingResponse(generate(), media_type="text/plain")
</code></pre>
<h2 id="heading-step-4-build-streamlit-ui">Step 4: Build Streamlit UI</h2>
<p>The Streamlit code creates a simple chat interface for the AI agent and keeps each browser session tied to a unique user_id.</p>
<p>When the app first loads, it generates and stores a UUID in st.session_state, which is later sent to the backend so the agent can keep that user’s conversation history separate from other users. It also creates a chat_history list in session state so previous messages remain visible every time Streamlit reruns the script. The app then loops through that saved history and displays each message in a chat-style format using st.chat_message().</p>
<p>When the user enters a new message through st.chat_input(), the app immediately saves and displays it, then sends it to the backend API with a POST request to <code>http://127.0.0.1:8001/chat/stream</code> along with the session’s user_id.</p>
<p>The request is made with stream=True, which allows the response to arrive gradually instead of all at once. As each chunk of text is received from the backend, the code appends it to full_answer and updates a placeholder on the page, creating a live streaming effect. Once the response is complete, the final assistant message is stored in chat_history so it remains part of the conversation on the page</p>
<p>Save the below as <code>streamlit_app.py</code></p>
<pre><code class="language-python">import uuid
import requests
import streamlit as st

API_URL = "http://127.0.0.1:8001/chat/stream"

st.title("Local AI Agent")

if "user_id" not in st.session_state:
    st.session_state.user_id = str(uuid.uuid4())

if "chat_history" not in st.session_state:
    st.session_state.chat_history = []

# Show previous messages
for item in st.session_state.chat_history:
    with st.chat_message(item["role"]):
        st.markdown(item["content"])

message = st.chat_input("Enter a message")

if message:
    # Save and show user message
    st.session_state.chat_history.append({"role": "user", "content": message})
    with st.chat_message("user"):
        st.markdown(message)

    # Stream assistant response
    full_answer = ""
    with st.chat_message("assistant"):
        placeholder = st.empty()

        # Send the reqeust to backend API via POST request
        with requests.post(
            API_URL,
            json={
                "message": message,
                "user_id": st.session_state.user_id,
            },
            stream=True,
        ) as response:
            response.raise_for_status()

            for chunk in response.iter_content(chunk_size=None, decode_unicode=True):
                if chunk:
                    full_answer += chunk
                    placeholder.markdown(full_answer)

    # Save final assistant response
    st.session_state.chat_history.append(
        {"role": "assistant", "content": full_answer}
    )
</code></pre>
<h2 id="heading-step-5-run-the-backend-app">Step 5: Run the Backend App</h2>
<p>Start the server with Uvicorn:</p>
<pre><code class="language-bash">uvicorn app:app --reload --port 8001
</code></pre>
<p>Once the application starts, open:</p>
<ul>
<li><p><code>http://127.0.0.1:8001/</code></p>
</li>
<li><p><code>http://127.0.0.1:8001/docs</code></p>
</li>
</ul>
<p>The <code>/docs</code> endpoint is automatically generated by FastAPI using your Pydantic models. It provides an interactive interface where you can test the API without writing any client code.</p>
<img src="https://cdn.hashnode.com/uploads/covers/684c95e159698b4bf6a0e4be/5cf32ff0-273c-47cd-80be-ebf807e4443d.png" alt="Api docs that was generated by FastAPI. It includes /chat/stream  endpoint and schema" style="display:block;margin:0 auto" width="2712" height="1034" loading="lazy">

<p>You can send requests directly from <code>curl</code>. In your terminal, run these commands to invoke the API for the AI agent and check the output:</p>
<pre><code class="language-bash">$ curl -X POST http://127.0.0.1:8001/chat/stream \
  -H "Content-Type: application/json" \
  -d '{"message":"What time is it?","user_id":"123e4567-e89b-12d3-a456-426614174000"}'

$ curl -X POST http://127.0.0.1:8001/chat/stream \
  -H "Content-Type: application/json" \
  -d '{"message":"How many words are in: LangChain makes tool calling easier","user_id":"123e4567-e89b-12d3-a456-426614174000"}'

$ curl -X POST "http://127.0.0.1:8001/chat/stream" \
-H "Content-Type: application/json" \
-d '{"message":"What is the capital of France?","user_id":"123e4567-e89b-12d3-a456-426614174000"}'
</code></pre>
<p>To stop the server, press Ctrl+C in the terminal.</p>
<h2 id="heading-step-6-run-the-frontend-app"><strong>Step 6: Run the Frontend App</strong></h2>
<p>In another terminal, go to the project directory:</p>
<pre><code class="language-plaintext">source venv/bin/activate
streamlit run streamlit_app.py
</code></pre>
<p>That opens the frontend in your browser at <code>http://localhost:8501/</code>. Try the example prompts like "What is the capital of France". You should see the answer in a chat style interface.</p>
<img src="https://cdn.hashnode.com/uploads/covers/684c95e159698b4bf6a0e4be/1030735a-49ed-43e1-995d-07b122c2c965.png" alt="Streamlit UI provides a simple chat frontend for the local AI agent" style="display:block;margin:0 auto" width="1848" height="1710" loading="lazy">

<p>The UI is calling the FastAPI endpoint and invoking the AI agent. You now have a working end to end application for your local AI agent that you can play with.</p>
<p>To stop the server, press Ctrl+C in the terminal.</p>
<h2 id="heading-sample-output">Sample Output</h2>
<p>The image below show two browser sessions of the app running side by side on the same endpoint. Each session is assigned a unique id, which allows the backend to maintain a separate conversation history for each user.</p>
<p>Even though both users ask the same question, “Who am I?”, the responses are different because each session’s answer is based on its own prior messages.</p>
<img src="https://cdn.hashnode.com/uploads/covers/684c95e159698b4bf6a0e4be/b97b8efa-6fca-4e80-9c0a-d0d2601fc2b6.png" alt="Image showing two sessions with the agent and it gives different answers based on the the conversation history" style="display:block;margin:0 auto" width="2914" height="1906" loading="lazy">

<h2 id="heading-what-to-improve-before-production">What to Improve Before Production</h2>
<p>Although this application is fully functional, it's still intentionally minimal. It already supports a reusable FastAPI backend, a Streamlit chat interface, per-user conversation history, and streaming responses.</p>
<p>If you wanted to take it further, the next steps would be adding authentication, persistent storage, structured logging, monitoring, and more robust deployment setup.</p>
<p>It's also worth noting that if your goal is simply to get a polished self-hosted chat UI up and running quickly, you may not need to build the frontend yourself. Projects like <a href="https://www.librechat.ai/">LibreChat</a> and <a href="https://docs.openwebui.com/">Open WebUI</a> already provide richer interfaces and broader features out of the box.</p>
<p>This tutorial takes a different approach: instead of adopting a full platform, it shows how to build a lightweight custom stack yourself so you can better understand the architecture and have more control over how the agent is exposed.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, we took a local AI agent, wrapped it in a FastAPI app, and used Streamlit UI on top of it.</p>
<p>This transforms the AI agent from a standalone script into a reusable service. Instead of only working in a terminal, it can now be accessed through a simple HTTP endpoint by other apps, scripts, or internal tools.</p>
<p>By assigning each session a unique id, the service can also maintain separate conversation history for multiple users, making it possible to support a chat-style interface with isolated memory per session.</p>
<p>From here, you can continue extending the same service by adding authentication or production-ready features. Happy tinkering!</p>
<p>If you enjoyed this tutorial, you can find more of my writing on my&nbsp;<a href="https://darshshah.org/blog/">blog</a>&nbsp;(recent posts include system design paper series), my work on my&nbsp;<a href="https://darshshah.org/">personal website</a>, and updates on&nbsp;<a href="https://www.linkedin.com/in/darshs">LinkedIn</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 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 Choose the Best Stock Market API for FinTech Projects and AI Agents  ]]>
                </title>
                <description>
                    <![CDATA[ Choosing a stock API looks simple until the project becomes real. At first, you only need a few prices. You send a request, get JSON back, load it into pandas, and move on. But the moment that API sta ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-choose-the-best-stock-market-api-for-fintech-projects-and-ai-agents/</link>
                <guid isPermaLink="false">6a24b9c567572e709df513c8</guid>
                
                    <category>
                        <![CDATA[ fintech ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #Stock market ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ai agents ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikhil Adithyan ]]>
                </dc:creator>
                <pubDate>Sun, 07 Jun 2026 00:22:29 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/e1f20d3c-eaf8-49e9-be53-4cc99eb971ec.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Choosing a stock API looks simple until the project becomes real.</p>
<p>At first, you only need a few prices. You send a request, get JSON back, load it into pandas, and move on. But the moment that API starts powering a backtester, dashboard, screener, valuation tool, or AI assistant, the decision becomes much more serious.</p>
<p>A backtester needs adjusted historical prices, splits, dividends, and stable time series. A dashboard needs fresh quotes, clean fields, and reliable responses. A stock screener needs fundamentals, ratios, and company metadata. An AI agent needs structured data that it can retrieve and use without guessing.</p>
<p>That's why I wouldn't start by comparing endpoint counts or pricing pages. Those matter, but they're not the first question.</p>
<p>The first question is: <strong>what are you building?</strong></p>
<p>In this article, we’ll walk through how to choose a stock market API based on the workflow it needs to support. Then we’ll build a practical stock research workflow in Python using Alpha Vantage to see how prices, fundamentals, technical indicators, and AI-ready access can fit together in one project.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-stock-api-choice-depends-on-the-workflow">Why Stock API Choice Depends On The Workflow</a></p>
<ul>
<li><p><a href="#heading-1-if-you-are-building-a-backtester">1. If You Are Building A Backtester</a></p>
</li>
<li><p><a href="#heading-2-if-you-are-building-a-dashboard">2. If You Are Building A Dashboard</a></p>
</li>
<li><p><a href="#heading-3-if-you-are-building-a-stock-screener">3. If You Are Building A Stock Screener</a></p>
</li>
<li><p><a href="#heading-4-if-you-are-building-a-valuation-or-research-tool">4. If You Are Building A Valuation Or Research Tool</a></p>
</li>
<li><p><a href="#heading-5-if-you-are-building-an-ai-assistant-or-agent">5. If You Are Building An AI Assistant Or Agent</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-a-modern-stock-market-data-workflow-actually-requires">What A Modern Stock Market Data Workflow Actually Requires</a></p>
</li>
<li><p><a href="#heading-building-a-practical-stock-research-workflow-with-alpha-vantage">Building A Practical Stock Research Workflow With Alpha Vantage</a></p>
<ul>
<li><p><a href="#heading-step-1-fetch-adjusted-historical-prices">Step 1: Fetch Adjusted Historical Prices</a></p>
</li>
<li><p><a href="#heading-step-2-add-company-or-fundamental-data">Step 2: Add Company Or Fundamental Data</a></p>
</li>
<li><p><a href="#heading-step-3-add-technical-indicators">Step 3: Add Technical Indicators</a></p>
</li>
<li><p><a href="#heading-step-4-combine-everything-into-a-research-ready-table">Step 4: Combine Everything Into A Research-Ready Table</a></p>
</li>
<li><p><a href="#heading-step-5-connect-the-workflow-to-ai-agents-with-mcp">Step 5: Connect The Workflow To AI Agents With MCP</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-where-each-provider-fits-in-the-stock-api-workflow">Where Each Provider Fits In The Stock API Workflow</a></p>
</li>
<li><p><a href="#provider-breakdown-through-a-workflow-lens">Provider Breakdown Through A Workflow Lens</a></p>
<ul>
<li><p><a href="#heading-1-when-the-project-needs-several-data-layers-alpha-vantage">1. When The Project Needs Several Data Layers: Alpha Vantage</a></p>
</li>
<li><p><a href="#heading-2-when-the-workflow-is-institutional-bloomberg-api">2. When The Workflow Is Institutional: Bloomberg API</a></p>
</li>
<li><p><a href="#heading-3-when-the-product-needs-investor-relations-widgets-quotemedia">3. When The Product Needs Investor Relations Widgets: QuoteMedia</a></p>
</li>
<li><p><a href="#heading-4-when-the-workflow-is-global-historical-research-eodhd">4. When The Workflow Is Global Historical Research: EODHD</a></p>
</li>
<li><p><a href="#5-when-the-workflow-needs-us-fundamentals-intrinio">5. When The Workflow Needs US Fundamentals: Intrinio</a></p>
</li>
<li><p><a href="#heading-6-when-the-workflow-needs-enterprise-data-delivery-xignite">6. When The Workflow Needs Enterprise Data Delivery: Xignite</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-final-checklist-before-choosing-a-stock-api">Final Checklist Before Choosing A Stock API</a></p>
</li>
<li><p><a href="#heading-final-thoughts">Final Thoughts</a></p>
</li>
</ul>
<h2 id="heading-why-stock-api-choice-depends-on-the-workflow"><strong>Why Stock API Choice Depends On The Workflow</strong></h2>
<p>A stock API should be judged by the workflow it supports, not by how long its feature list looks. The same provider can be a good fit for one project and a weak fit for another.</p>
<p>A clean historical dataset matters more for a backtester than a live quote endpoint. A dashboard has different problems. It needs fresh responses, predictable fields, and rate limits that don't collapse once users start refreshing the page.</p>
<p>Here is how I would think about it.</p>
<h3 id="heading-1-if-you-are-building-a-backtester">1. If You Are Building A Backtester</h3>
<h4 id="heading-start-with-historical-data-quality">Start with historical data quality.</h4>
<p>A backtest needs adjusted prices, splits, dividends, long history, and stable time series. If those pieces are wrong, the backtest can still run, but the results may be misleading.</p>
<p>For this workflow, real-time data is usually secondary. Clean historical data matters more than fast quotes.</p>
<h3 id="heading-2-if-you-are-building-a-dashboard">2. If You Are Building A Dashboard</h3>
<h4 id="heading-start-with-freshness-and-reliability">Start with freshness and reliability.</h4>
<p>A dashboard needs quote data that updates consistently, fields that don't change unexpectedly, and rate limits that can handle repeated requests. A failed request in a notebook is annoying. A failed request in a user-facing dashboard is a product problem.</p>
<p>You also need to check whether the data can be displayed to users. Licensing becomes part of the workflow once the dashboard is public.</p>
<h3 id="heading-3-if-you-are-building-a-stock-screener">3. If You Are Building A Stock Screener</h3>
<h4 id="heading-start-with-fundamentals-and-structured-fields">Start with fundamentals and structured fields.</h4>
<p>A screener needs more than prices. It may need ratios, company profiles, sector data, market cap, earnings, and symbol coverage across many companies.</p>
<p>The hard part is comparison. If fields are inconsistent across tickers, the screener becomes a cleanup project before it becomes a useful tool.</p>
<h3 id="heading-4-if-you-are-building-a-valuation-or-research-tool">4. If You Are Building A Valuation Or Research Tool</h3>
<h4 id="heading-start-with-financial-statements">Start with financial statements.</h4>
<p>A valuation workflow usually needs income statements, balance sheets, cash flow statements, earnings history, and historical fundamentals. Price data gives market context, but the business data does the heavier work.</p>
<p>This is where depth matters. The latest numbers are useful, but trends across multiple periods are often more important.</p>
<h3 id="heading-5-if-you-are-building-an-ai-assistant-or-agent">5. If You Are Building An AI Assistant Or Agent</h3>
<h4 id="heading-start-with-structure">Start with structure.</h4>
<p>An AI agent shouldn't guess financial data from memory. It needs predictable API responses, clear schemas, and tool access it can use reliably.</p>
<p>This is where MCP-style workflows matter. If an agent can call a tool, retrieve a quote, pull fundamentals, or fetch a time series cleanly, the API becomes part of the agent’s reasoning loop.</p>
<p>The practical point is simple: choose the API around the system you're building. Once the workflow is clear, the rest of the decision becomes much easier.</p>
<h2 id="heading-what-a-modern-stock-market-data-workflow-actually-requires"><strong>What A Modern Stock Market Data Workflow Actually Requires</strong></h2>
<p>A modern stock data workflow is rarely just one API call.</p>
<p>You might start with market data, but most useful projects eventually need more layers. A research dashboard may need fundamentals. A screener may need technical indicators. An AI assistant may need structured responses that it can retrieve through a tool.</p>
<p>A simple way to think about the workflow is:</p>
<p><code>Market Data -&gt; Fundamentals -&gt; Indicators -&gt; Structured Responses -&gt; Programmatic Workflow -&gt; AI/Agent Access</code></p>
<p>Each layer solves a different problem.</p>
<ul>
<li><p><strong>Market data</strong> gives you prices, volume, returns, and historical movement.</p>
</li>
<li><p><strong>Fundamentals</strong> add business context through revenue, margins, cash flow, earnings, and company details.</p>
</li>
<li><p><strong>Indicators</strong> help convert raw prices into features that can support screening, research, or signal testing.</p>
</li>
<li><p><strong>Structured responses</strong> make the data easier to parse, join, and reuse.</p>
</li>
<li><p><strong>Programmatic workflows</strong> turn the raw API response into tables, charts, models, dashboards, or research outputs.</p>
</li>
<li><p><strong>AI or agent access</strong> lets an assistant call tools, retrieve current data, and work with structured financial context instead of relying only on static knowledge.</p>
</li>
</ul>
<p>This is why stock API choice matters beyond the first request. The API is not only there to return data but to support the way the project grows after the prototype.</p>
<h2 id="heading-building-a-practical-stock-research-workflow-with-alpha-vantage"><strong>Building A Practical Stock Research Workflow With Alpha Vantage</strong></h2>
<p>Now let’s turn the framework into something practical.</p>
<p>For this section, we’ll use Alpha Vantage as the implementation API because it gives us the main layers we need for this workflow: adjusted historical prices, company data, technical indicators, and MCP-style access for AI agents.</p>
<p>The goal isn't to test every endpoint. The goal is to build a small research workflow that shows what a useful stock API should help us do.</p>
<p>We’ll build this in five steps:</p>
<ol>
<li><p>Fetch adjusted historical prices.</p>
</li>
<li><p>Add company or fundamental data.</p>
</li>
<li><p>Add a technical indicator.</p>
</li>
<li><p>Combine everything into a research-ready table.</p>
</li>
<li><p>Connect the workflow to an AI-agent setup using MCP.</p>
</li>
</ol>
<p>By the end, we should have a simple but practical stock research table that can support a screener, dashboard, research notebook, or AI assistant.</p>
<h3 id="heading-step-1-fetch-adjusted-historical-prices">Step 1: Fetch Adjusted Historical Prices</h3>
<p>Adjusted prices are the first thing I would check for any research or backtesting workflow. Raw prices can break around stock splits or dividends, while adjusted prices keep the series more useful for return calculations.</p>
<p>Let’s fetch daily adjusted price data for Apple.</p>
<pre><code class="language-python">import requests
import pandas as pd

api_key = 'YOUR ALPHA VANTAGE API KEY'

symbol = 'AAPL'

url = f'https://www.alphavantage.co/query?function=TIME_SERIES_DAILY_ADJUSTED&amp;symbol={symbol}&amp;outputsize=compact&amp;apikey={api_key}'

response = requests.get(url)
data = response.json()

prices = pd.DataFrame(data['Time Series (Daily)']).T

prices.index = pd.to_datetime(prices.index)
prices = prices.sort_index()

prices = prices.rename(columns={
    '1. open': 'open',
    '2. high': 'high',
    '3. low': 'low',
    '4. close': 'close',
    '5. adjusted close': 'adjusted_close',
    '6. volume': 'volume',
    '7. dividend amount': 'dividend',
    '8. split coefficient': 'split'
})

price_cols = ['open', 'high', 'low', 'close', 'adjusted_close', 'volume', 'dividend', 'split']
prices[price_cols] = prices[price_cols].astype(float)

prices.tail()
</code></pre>
<p>The output gives us a clean daily price table as you can see in the image below:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/903925ac-462b-4684-9b51-98b6f6173f74.png" alt="903925ac-462b-4684-9b51-98b6f6173f74" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>For a chart, you may only need <code>close</code>. For research or backtesting, I would usually work with <code>adjusted_close</code> because it handles corporate actions more safely. Next, we can convert the time series into a few basic price features.</p>
<pre><code class="language-python">latest_price = prices['adjusted_close'].iloc[-1] 
return_30d = prices['adjusted_close'].pct_change(30).iloc[-1] 
volatility_30d = prices['adjusted_close'].pct_change().tail(30).std() 

price_features = {'symbol': symbol, 'latest_price': latest_price, 'return_30d': return_30d, 'volatility_30d': volatility_30d}
price_features
</code></pre>
<p>This returns:</p>
<pre><code class="language-plaintext">{'symbol': 'AAPL',
 'latest_price': 312.06,
 'return_30d': 0.18583097277442007,
 'volatility_30d': 0.012845143800989936}
</code></pre>
<p>This is already more useful than a raw API response. We now have a small set of price features that can feed a dashboard, screener, research table, or AI-assisted stock analysis workflow.</p>
<h3 id="heading-step-2-add-company-or-fundamental-data">Step 2: Add Company Or Fundamental Data</h3>
<p>Price data tells us how the stock moved, but it doesn't tell us much about the company behind the ticker. For a screener, valuation tool, or research workflow, we need some business context too.</p>
<p>Alpha Vantage’s OVERVIEW endpoint gives company-level fields like sector, industry, market cap, PE ratio, EPS, profit margin, and other summary metrics. Let’s pull those fields and keep only the ones we need for this workflow.</p>
<pre><code class="language-python">overview_url = f'https://www.alphavantage.co/query?function=OVERVIEW&amp;symbol={symbol}&amp;apikey={api_key}'

response = requests.get(overview_url)
overview = response.json()

fundamental_features = {
    'symbol': symbol,
    'name': overview.get('Name'),
    'sector': overview.get('Sector'),
    'industry': overview.get('Industry'),
    'market_cap': overview.get('MarketCapitalization'),
    'pe_ratio': overview.get('PERatio'),
    'eps': overview.get('EPS'),
    'profit_margin': overview.get('ProfitMargin'),
    'beta': overview.get('Beta')
}

fundamental_features
</code></pre>
<p>This returns:</p>
<pre><code class="language-plaintext">{'symbol': 'AAPL',
 'name': 'Apple Inc',
 'sector': 'TECHNOLOGY',
 'industry': 'CONSUMER ELECTRONICS',
 'market_cap': 4583336182000.0,
 'pe_ratio': 37.73,
 'eps': 8.27,
 'profit_margin': 0.272,
 'beta': 1.065}
</code></pre>
<p>Now we have two layers: price behavior from the time series data and business context from the company overview. The next step is to add a technical indicator so the table includes a market-derived signal as well.</p>
<h3 id="heading-step-3-add-technical-indicators">Step 3: Add Technical Indicators</h3>
<p>Fundamentals give us business context, but many research workflows also need market-derived signals. A simple example is the relative strength index, or RSI, which is often used to measure recent momentum.</p>
<p>Alpha Vantage has a RSI endpoint, so we can pull the indicator directly instead of calculating it from scratch.</p>
<pre><code class="language-python">rsi_url = f'https://www.alphavantage.co/query?function=RSI&amp;symbol={symbol}&amp;interval=daily&amp;time_period=14&amp;series_type=close&amp;apikey={api_key}'

response = requests.get(rsi_url)
rsi_data = response.json()

rsi = pd.DataFrame(rsi_data['Technical Analysis: RSI']).T

rsi.index = pd.to_datetime(rsi.index)
rsi = rsi.sort_index()
rsi['RSI'] = rsi['RSI'].astype(float)

latest_rsi = rsi['RSI'].iloc[-1]

indicator_features = {
    'symbol': symbol,
    'rsi_14': latest_rsi
}

indicator_features
</code></pre>
<p>This returns:</p>
<pre><code class="language-plaintext">{'symbol': 'AAPL', 'rsi_14': 79.0043}
</code></pre>
<p>Now the workflow has three layers:</p>
<ul>
<li><p>price behavior from adjusted historical data</p>
</li>
<li><p>business context from company fundamentals</p>
</li>
<li><p>momentum context from a technical indicator</p>
</li>
</ul>
<p>None of these is enough on its own. Together, they start to look like a usable research workflow instead of a raw API test.</p>
<h3 id="heading-step-4-combine-everything-into-a-research-ready-table">Step 4: Combine Everything Into A Research-Ready Table</h3>
<p>Now we can combine the price, fundamentals, and indicator layers into one table.</p>
<p>This is the part that matters for most real projects. A dashboard, screener, notebook, or AI assistant usually needs a clean object it can reuse, not three separate raw API responses.</p>
<pre><code class="language-python">research_row = {
    **price_features,
    **fundamental_features,
    **indicator_features
}

research_table = pd.DataFrame([research_row])

research_table
</code></pre>
<p>This gives us a single-row research table:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/5d659e28-19e3-4455-a1d8-e9bbd02e3ace.png" alt="research table" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This table is simple, but it already supports several use cases.</p>
<p>A screener can filter on <code>pe_ratio</code>, <code>profit_margin</code>, or <code>rsi_14</code>. A dashboard can show price, returns, sector, and market cap. A research notebook can add more tickers and compare them. An AI assistant can receive this as a compact context object instead of parsing multiple API responses on its own.</p>
<p>That's the real benefit of building the workflow this way. The API calls are only the beginning. The useful output is the structured table you create from them.</p>
<h3 id="heading-step-5-connect-the-workflow-to-ai-agents-with-mcp">Step 5: Connect The Workflow To AI Agents With MCP</h3>
<p>The table we created is useful because it has a predictable structure, which is exactly what AI workflows need.</p>
<p>If an agent needs stock context, it shouldn't guess from memory or parse several raw API responses every time. It should call a tool, retrieve the data, and receive something clean enough to use.</p>
<p>A simplified MCP workflow looks like this:</p>
<p><code>User question -&gt; AI agent -&gt; MCP tool call -&gt; Stock API data -&gt; Structured response -&gt; Final answer</code></p>
<p>For example, a user might ask:</p>
<p><em>Is Apple looking expensive compared with its recent momentum?</em></p>
<p>An agent could retrieve price data, fundamentals, and an indicator such as RSI before answering. The important part is not that the model already “knows” the answer. It's that the model can call the right tool and work with current data.</p>
<p>That is where our research table helps:</p>
<pre><code class="language-python">research_table.to_dict(orient='records')[0]
</code></pre>
<p>This returns a compact dictionary:</p>
<pre><code class="language-plaintext">{'symbol': 'AAPL',
 'latest_price': 312.06,
 'return_30d': 0.18583097277442007,
 'volatility_30d': 0.012845143800989936,
 'name': 'Apple Inc',
 'sector': 'TECHNOLOGY',
 'industry': 'CONSUMER ELECTRONICS',
 'market_cap': 4583336182000.0,
 'pe_ratio': 37.73,
 'eps': 8.27,
 'profit_margin': 0.272,
 'beta': 1.065,
 'rsi_14': 79.0043}
</code></pre>
<p>This doesn't replace proper analysis, and it shouldn't be treated as investment advice. But it gives an AI assistant a cleaner starting point than raw JSON, stale model knowledge, or a vague prompt with no data attached.</p>
<p>AI readiness isn't just about saying an API supports agents. The API has to return data that can be retrieved, structured, checked, and passed into a workflow without fragile glue code at every step.</p>
<h2 id="heading-where-each-provider-fits-in-the-stock-api-workflow"><strong>Where Each Provider Fits In The Stock API Workflow</strong></h2>
<p>The workflow we built above is one version of a modern stock data project: prices, fundamentals, indicators, programmatic analysis, and AI-agent access working together.</p>
<p>Other projects may need a narrower or more specialized provider. Here's a practical way to compare the fit:</p>
<table style="min-width:653px"><colgroup><col style="min-width:25px"><col style="width:84px"><col style="width:75px"><col style="width:87px"><col style="width:90px"><col style="width:88px"><col style="width:83px"><col style="width:121px"></colgroup><tbody><tr><td><p><strong>Provider</strong></p></td><td><p><strong>Market Data</strong></p></td><td><p><strong>Fundamentals</strong></p></td><td><p><strong>Technical Indicators</strong></p></td><td><p><strong>Developer Workflow</strong></p></td><td><p><strong>AI / Agent Readiness</strong></p></td><td><p><strong>Workflow Completeness</strong></p></td><td><p><strong>Best Fit</strong></p></td></tr><tr><td><p>Alpha Vantage</p></td><td><p>Strong</p></td><td><p>Strong</p></td><td><p>Strong</p></td><td><p>Strong</p></td><td><p>Strong</p></td><td><p>High</p></td><td><p>Broad technical projects, research tools, screeners, dashboards, and AI-agent workflows</p></td></tr><tr><td><p>Bloomberg API</p></td><td><p>Very strong</p></td><td><p>Strong</p></td><td><p>Moderate</p></td><td><p>Enterprise-focused</p></td><td><p>Enterprise-dependent</p></td><td><p>High</p></td><td><p>Institutions already using Bloomberg internally</p></td></tr><tr><td><p>QuoteMedia</p></td><td><p>Strong</p></td><td><p>Moderate</p></td><td><p>Limited / Moderate</p></td><td><p>Moderate</p></td><td><p>Limited</p></td><td><p>Medium</p></td><td><p>Investor relations websites and embedded market data widgets</p></td></tr><tr><td><p>EODHD</p></td><td><p>Strong</p></td><td><p>Good</p></td><td><p>Good</p></td><td><p>Good</p></td><td><p>Strong</p></td><td><p>High</p></td><td><p>Global EOD history, backtesting, and historical research</p></td></tr><tr><td><p>Intrinio</p></td><td><p>Good</p></td><td><p>Strong</p></td><td><p>Limited / Moderate</p></td><td><p>Good</p></td><td><p>Limited / Moderate</p></td><td><p>Medium / High</p></td><td><p>US fundamentals, valuation tools, and professional datasets</p></td></tr><tr><td><p>Xignite</p></td><td><p>Strong</p></td><td><p>Good</p></td><td><p>Limited / Moderate</p></td><td><p>Enterprise-focused</p></td><td><p>Limited / Moderate</p></td><td><p>Medium / High</p></td><td><p>Enterprise financial applications needing vendor support</p></td></tr></tbody></table>

<p>No provider fits every workflow equally well. The point of this table is to show where the fit is strongest.</p>
<p>Alpha Vantage works well when a project needs several layers together, especially market data, fundamentals, indicators, developer usability, and AI-agent access. EODHD is stronger when the workflow is centered on global historical research. Intrinio fits better when standardized US fundamentals are the main requirement. Bloomberg API and Xignite are more natural for institutional or enterprise environments, while QuoteMedia is more specialized around investor relations and embedded market data widgets.</p>
<p>This is the right way to think about stock APIs: not as one universal winner, but as different tools for different workflow shapes.</p>
<h2 id="heading-provider-breakdown-through-a-workflow-lens"><strong>Provider Breakdown Through A Workflow Lens</strong></h2>
<p>The table gives a quick comparison. This section explains what that means in practice.</p>
<p>Instead of asking which provider is “best” in general, it is better to ask: what kind of workflow is this provider naturally built for?</p>
<h3 id="heading-1-when-the-project-needs-several-data-layers-alpha-vantage">1. When The Project Needs Several Data Layers: Alpha Vantage</h3>
<p>Alpha Vantage fits well when the project needs more than one type of market data in the same workflow.</p>
<p>In the workflow we built earlier, we used:</p>
<ul>
<li><p>adjusted historical prices</p>
</li>
<li><p>company data</p>
</li>
<li><p>technical indicators</p>
</li>
<li><p>structured output for programmatic analysis</p>
</li>
<li><p>a format that can also support AI-agent workflows</p>
</li>
</ul>
<p>That makes Alpha Vantage a flexible fit for stock research notebooks, screeners, dashboards, backtesting workflows, and AI assistants that need market data through tools or MCP-style access.</p>
<p>The main caveat is specialization. If your project needs direct exchange infrastructure, co-location, or a highly specialized institutional setup, you may need a more specialized provider. But for most research, fintech apps, and AI workflows, Alpha Vantage gives enough breadth without forcing you to combine several APIs too early.</p>
<h3 id="heading-2-when-the-workflow-is-institutional-bloomberg-api">2. When The Workflow Is Institutional: Bloomberg API</h3>
<p>Bloomberg API makes sense when the organization already uses Bloomberg internally.</p>
<p>It's best suited for firms that want to connect Bloomberg data with internal tools, reports, models, and risk systems.</p>
<p>This isn't usually the right fit for solo developers or small teams. The cost, licensing, and ecosystem dependency make it more suitable for institutions.</p>
<h3 id="heading-3-when-the-product-needs-investor-relations-widgets-quotemedia">3. When The Product Needs Investor Relations Widgets: QuoteMedia</h3>
<p>QuoteMedia fits products where the main need is public-facing market data display.</p>
<p>That can include:</p>
<ul>
<li><p>investor relations pages</p>
</li>
<li><p>quote widgets</p>
</li>
<li><p>embedded charts</p>
</li>
<li><p>company stock pages</p>
</li>
<li><p>market data modules for public websites</p>
</li>
</ul>
<p>This is different from building a programmatic research workflow. QuoteMedia makes more sense when presentation and embedded financial data are the core product requirement.</p>
<h3 id="heading-4-when-the-workflow-is-global-historical-research-eodhd">4. When The Workflow Is Global Historical Research: EODHD</h3>
<p>EODHD fits well when the project needs broad historical data across global markets.</p>
<p>It's useful for long-horizon backtesting, global screeners, and research workflows that depend on end-of-day data from many exchanges.</p>
<p>The tradeoff is cleanup. Global data often brings differences in symbols, exchange calendars, currencies, and local market conventions. That's manageable, but it should be expected.</p>
<h3 id="heading-5-when-the-workflow-needs-us-fundamentals-intrinio">5. When The Workflow Needs US Fundamentals: Intrinio</h3>
<p>Intrinio fits well when standardized US fundamentals are the center of the product.</p>
<p>It's useful for:</p>
<ul>
<li><p>valuation tools</p>
</li>
<li><p>earnings dashboards</p>
</li>
<li><p>fundamentals-based screeners</p>
</li>
<li><p>professional US equity research workflows</p>
</li>
</ul>
<p>The main thing to check is dataset fit. Before building around Intrinio, I would look closely at the specific datasets, access terms, and coverage levels the product needs.</p>
<h3 id="heading-6-when-the-workflow-needs-enterprise-data-delivery-xignite">6. When The Workflow Needs Enterprise Data Delivery: Xignite</h3>
<p>Xignite fits larger financial applications that need formal vendor support.</p>
<p>This can include banks, brokerages, wealth platforms, and enterprise fintech products where support, contracts, reliability, and data relationships matter as much as the endpoint itself.</p>
<p>For smaller developer projects, it may feel heavier than necessary. For enterprise products, that structure can be exactly the point.</p>
<h2 id="heading-final-checklist-before-choosing-a-stock-api"><strong>Final Checklist Before Choosing A Stock API</strong></h2>
<p>Before choosing a provider, I would run through this checklist.</p>
<table style="min-width:428px"><colgroup><col style="min-width:25px"><col style="width:403px"></colgroup><tbody><tr><td><p><strong>Question</strong></p></td><td><p><strong>Why It Matters</strong></p></td></tr><tr><td><p>What am I building?</p></td><td><p>A backtester, dashboard, screener, valuation tool, and AI assistant all need different things.</p></td></tr><tr><td><p>Do I need real-time, delayed, or historical data?</p></td><td><p>Real-time access matters only if the workflow actually needs it.</p></td></tr><tr><td><p>Do I need adjusted prices?</p></td><td><p>For backtesting and research, adjusted prices are usually non-negotiable.</p></td></tr><tr><td><p>Do I need fundamentals?</p></td><td><p>Screeners, valuation tools, and research dashboards usually need company data, not just prices.</p></td></tr><tr><td><p>Do I need technical indicators?</p></td><td><p>Signal testing, filters, and momentum-style analysis may need indicators directly from the API or calculated separately.</p></td></tr><tr><td><p>How many symbols will I query?</p></td><td><p>One ticker in a notebook is easy. Hundreds of tickers can expose rate-limit and performance issues quickly.</p></td></tr><tr><td><p>Will users see the data?</p></td><td><p>If yes, licensing, display rights, storage rules, and redistribution terms matter before the product goes live.</p></td></tr><tr><td><p>Is the response easy to parse in Python or other programming languages?</p></td><td><p>Clean JSON can save a lot of cleanup work once the project grows.</p></td></tr><tr><td><p>Can it support AI or agent workflows?</p></td><td><p>AI assistants need structured responses, tool compatibility, or MCP-style access.</p></td></tr><tr><td><p>Will this API still work after the prototype stage?</p></td><td><p>A provider can be easy to try and still be hard to build around.</p></td></tr></tbody></table>

<h2 id="heading-final-thoughts"><strong>Final Thoughts</strong></h2>
<p>A good stock API should reduce project risk, not just return data.</p>
<p>If you're building a small chart, almost any clean price endpoint can work. But once the same API starts supporting a backtester, screener, dashboard, valuation tool, or AI assistant, the decision becomes more important. The provider affects your data quality, parsing logic, refresh jobs, licensing choices, and future product direction.</p>
<p>This is why workflow fit matters more than endpoint count. For projects that need several layers together, such as real-time and historical market data, fundamentals, indicators, developer-friendly access, spreadsheet support, and MCP-style AI workflows, Alpha Vantage fits well. For narrower workflow needs, another provider may make more sense.</p>
<p>Choose the API as part of your project’s data infrastructure, not just as a list of endpoints.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Design APIs for AI Agents ]]>
                </title>
                <description>
                    <![CDATA[ APIs are designed for human developers. People read documentation, infer the intent behind an endpoint, and know how to handle edge cases when something unexpected happens. AI agents don't have that c ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-design-apis-for-ai-agents/</link>
                <guid isPermaLink="false">6a18bdb078258754833f8205</guid>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ai-agent ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ David Aniebo ]]>
                </dc:creator>
                <pubDate>Thu, 28 May 2026 22:12:00 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/056b20d6-7409-4b6e-a29c-0b48061a7508.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>APIs are designed for human developers. People read documentation, infer the intent behind an endpoint, and know how to handle edge cases when something unexpected happens.</p>
<p>AI agents don't have that context and understanding.</p>
<p>AI agent understand APIs through schemas, examples, randomized data and live responses. When a behavior or method is ambiguous and inconsistent, the model doesn't pause to “think” – it fills in the blanks (randomizing).</p>
<p>In production, those guesses could become blocks, retry storms, duplicated side effects, or broken workflows.</p>
<p>This is why APIs that are perfectly fine for humans frequently fail under AI agent use. The problem is rarely “the agent isn’t smart enough.” More often, the API was never designed for an agent/machine consumer that must plan, call tools, and recover from failure without a human in the loop.</p>
<p>In this guide, you’ll learn how to design APIs that agents can use reliably. We’ll anchor the discussion in three practical ideas:</p>
<ol>
<li><p><strong>Deterministic behavior:</strong> same inputs and state should yield predictable outcomes and shapes.</p>
</li>
<li><p><strong>Strong schemas:</strong> contracts that are complete, descriptive, and testable.</p>
</li>
<li><p><strong>Guardrails at the API boundary:</strong> authorization, validation, and safe defaults that prevent unsafe autonomy.</p>
</li>
</ol>
<p>The aim of this article is not to build “AI-powered” APIs, but rather to build APIs that are <strong>clear, strict,</strong> and <strong>dependable,</strong> even when the caller is not an agent but a fellow developers leveraging various tools.</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-why-good-enough-for-devs-is-not-good-enough-for-agents">Why “Good Enough for Devs” Is Not Good Enough for Agents</a></p>
</li>
<li><p><a href="#heading-principle-1-deterministic-behavior">Principle 1: Deterministic Behavior</a></p>
</li>
<li><p><a href="#heading-principle-2-strong-schemas">Principle 2: Strong Schemas</a></p>
</li>
<li><p><a href="#heading-principle-3-guardrails-at-the-api-boundary">Principle 3: Guardrails at the API Boundary</a></p>
</li>
<li><p><a href="#heading-patterns-that-bridge-apis-and-agent-runtimes">Patterns That Bridge APIs and Agent Runtimes</a></p>
</li>
<li><p><a href="#heading-a-practical-before-and-after-example">A Practical Before and After Example</a></p>
</li>
<li><p><a href="#heading-checklist-is-your-api-agent-ready">Checklist: Is Your API Agent-Ready?</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before reading this guide, it helps to have:</p>
<ul>
<li><p>A basic understanding of HTTP APIs and REST concepts</p>
</li>
<li><p>Familiarity with JSON and API request/response patterns</p>
</li>
<li><p>An understanding of common API concepts like authentication, pagination, and retries</p>
</li>
</ul>
<h2 id="heading-why-good-enough-for-devs-is-not-good-enough-for-agents">Why “Good Enough for Devs” Is Not Good Enough for Agents</h2>
<p>Human developers bring implied and contextual knowledge: they read through Slack threads, read blog posts, and recognize that “this 404 usually means you forgot the workspace ID.”</p>
<p>Agents mostly get whatever is in the spec, the examples, and the last response body.</p>
<p>That gap shows up in predictable ways:</p>
<ul>
<li><p><strong>Ambiguous semantics:</strong> wrong endpoint or wrong parameter combination.</p>
</li>
<li><p><strong>Undocumented branches:</strong> the model invents fields or misreads optional behavior.</p>
</li>
<li><p><strong>Inconsistent error bodies:</strong> retries that shouldn't happen, or no retry when one is safe.</p>
</li>
<li><p><strong>Non-idempotent “do things” endpoints:</strong> duplicate charges, duplicate tickets, duplicate emails.</p>
</li>
</ul>
<p>Industry commentary and practitioner guides converge on the same point: agents are becoming a major class of API consumer, and machine legibility matters as much as developer experience.</p>
<p>See for example discussions of OpenAPI as the source of truth for agents, emerging tool protocols, and traffic patterns that differ from human clients in the resources listed at the end of this article.</p>
<h2 id="heading-principle-1-deterministic-behavior">Principle 1: Deterministic Behavior</h2>
<p>Determinism for agents doesn't mean “always return the same JSON forever.” It means: <strong>given the same request and the same server-side state, your API behaves in a way the agent can model</strong> and when state changes, you make that explicit.</p>
<h3 id="heading-prefer-explicit-state-over-hidden-magic">Prefer Explicit State Over Hidden Magic</h3>
<p>Agents struggle with “sometimes the server does X depending on internal flags.” Where humans infer intent from product copy, agents infer from patterns. If those patterns drift, autonomy breaks.</p>
<p>Practical habits:</p>
<ul>
<li><p>Model lifecycle explicitly (<code>draft</code> → <code>submitted</code> → <code>approved</code>) instead of overloading a single <code>status</code> field with undocumented combinations.</p>
</li>
<li><p>Return what changed after mutations (updated resource, relevant IDs, next allowed actions).</p>
</li>
<li><p>Avoid silent coercion (auto-correcting bad enums, silently dropping unknown fields) unless you document and signal it.</p>
</li>
</ul>
<h3 id="heading-make-writes-safe-idempotency-and-intent-keys">Make Writes Safe: Idempotency and Intent Keys</h3>
<p>For any endpoint that bills, sends messages, provisions infrastructure, or otherwise <strong>does something irreversible</strong>, assume double-submission will happen.</p>
<ul>
<li><p>Support idempotency keys (header or body) for create-like operations.</p>
</li>
<li><p>Use clear HTTP semantics: <code>POST</code> creates, <code>PUT</code> replaces where appropriate, <code>PATCH</code> for partial updates and document what repeats mean.</p>
</li>
<li><p>Where duplicates are possible, offer a lookup-by-client-reference path so agents can reconcile.</p>
</li>
</ul>
<h3 id="heading-pagination-and-sorting-one-pattern-everywhere">Pagination and Sorting: One Pattern, Everywhere</h3>
<p>Agents loop. If every resource paginates differently, the model will mix strategies.</p>
<p>To combat this, pick one pagination style (cursor vs offset) per API surface and stick to it.</p>
<p>Also, always return stable sort order or require <code>sort</code> explicitly. You should also include <code>next</code> links or cursors in a consistent envelope.</p>
<h3 id="heading-timeouts-partial-success-and-async-work">Timeouts, Partial Success, and Async Work</h3>
<p>Agents hate “maybe it worked.” Long-running work should be <strong>explicitly async</strong>:</p>
<ul>
<li><p><code>202 Accepted</code> + job ID + polling or webhooks.</p>
</li>
<li><p>Clear terminal states: <code>succeeded</code>, <code>failed</code>, <code>canceled</code>, with structured error details on failure.</p>
</li>
</ul>
<h2 id="heading-principle-2-strong-schemas">Principle 2: Strong Schemas</h2>
<p>If determinism is about behavior, schemas are about communication. For agents, your OpenAPI (or equivalent) isn't paperwork, it's part of the runtime interface.</p>
<h3 id="heading-treat-openapi-as-a-contract-not-a-souvenir">Treat OpenAPI as a Contract, Not a Souvenir</h3>
<p>A specification that lags production is worse than no spec: it trains the agent to be confidently wrong. Teams increasingly treat OpenAPI as the authoritative contract and validate requests/responses against it in CI and at the edge.</p>
<p>Here's the minimum bar for agent-friendly OpenAPI:</p>
<ul>
<li><p>Every operation has a <code>summary</code> and a <code>description</code> that explain <em>when</em> to use it, not only <em>what</em> it returns.</p>
</li>
<li><p>Every request body property has <code>description</code> and realistic <code>example</code> values.</p>
</li>
<li><p>All responses are documented including 4xx/5xx with stable JSON shapes.</p>
</li>
</ul>
<h3 id="heading-describe-intent-in-natural-language-precisely">Describe Intent in Natural Language, Precisely</h3>
<p>Agents aren't offended by verbosity. They're confused by vague verbs.</p>
<p>Instead of:</p>
<blockquote>
<p>“Gets orders.”</p>
</blockquote>
<p>Prefer:</p>
<blockquote>
<p>“Lists orders for the authenticated merchant. Supports filtering by <code>status</code> and a time window on <code>created_at</code>. Returns at most <code>limit</code> items; use <code>cursor</code> for the next page.”</p>
</blockquote>
<p>This aligns with what multiple guides call <strong>context-aware</strong> or <strong>self-describing</strong> APIs: the schema carries semantic intent, not just types.</p>
<h3 id="heading-examples-are-part-of-the-contract">Examples Are Part of the Contract</h3>
<p>You should provide a happy path example per endpoint, at least one validation error example (400) with your standard error object, and examples for optional fields when they change behavior.</p>
<p>Examples reduce “shape hallucination” where the model guesses field names or nesting.</p>
<h3 id="heading-json-schema-strictness-helps-tool-calling-stacks">JSON Schema Strictness Helps Tool-Calling Stacks</h3>
<p>If your agent uses function calling / structured outputs, tighten schemas:</p>
<ul>
<li><p>Prefer <code>enum</code> for small closed sets.</p>
</li>
<li><p>Mark fields <code>required</code> honestly.</p>
</li>
<li><p>Use <code>format</code> (<code>uuid</code>, <code>date-time</code>) where real.</p>
</li>
<li><p>Avoid <code>additionalProperties: true</code> on security-sensitive payloads if you need strict validation.</p>
</li>
</ul>
<h3 id="heading-name-things-consistently">Name Things Consistently</h3>
<p><code>userId</code> in one endpoint and <code>user_id</code> in another is a human annoyance and an agent trap. Pick a convention and enforce it.</p>
<h2 id="heading-principle-3-guardrails-at-the-api-boundary">Principle 3: Guardrails at the API Boundary</h2>
<p>Autonomy amplifies mistakes. Guardrails turn “oops” into blocked requests instead of incidents.</p>
<h3 id="heading-authorization-should-be-narrow-and-explicit">Authorization Should Be Narrow and Explicit</h3>
<p>Agents should receive credentials scoped to <strong>least privilege</strong>. For example, use short-lived tokens, with refresh documented clearly. Use scopes that map to real actions (<code>orders:read</code> vs <code>orders:write</code>). And avoid flows that assume a human can solve (CAPTCHAs) or click (email links mid-run) or isolate those as human-in-the-loop tools.</p>
<h3 id="heading-validate-hard-fail-loud-and-structured">Validate Hard, Fail Loud and Structured</h3>
<p>Reject bad input at the edge with stable <code>error_code</code> values (machine-actionable), human-readable <code>message</code> (for logs and UI), optional <code>field</code> or JSON Pointer to the problem, and optional <code>doc_url</code> linking to documentation.</p>
<p>This matches guidance from several practitioner articles: opaque 500s and generic errors are where autonomous clients spiral.</p>
<p>RFC 7807 Problem Details (<code>application/problem+json</code>) is a good, widely understood pattern for HTTP APIs, a structured envelope agents can parse consistently.</p>
<h3 id="heading-separate-read-the-world-from-change-the-world">Separate “Read the World” from “Change the World”</h3>
<p>For high-impact actions (refunds, deletes, transfers), consider using a two-step pattern: first create an intent, then confirm execution.</p>
<p>Or you can dry-run query parameters / dedicated endpoints that validate without committing.</p>
<p>Also keep in mind that rate limits and quotas tuned for bursty agent behavior and autonomous loops can dwarf human traffic.</p>
<h3 id="heading-observability-is-a-product-feature">Observability is a Product Feature</h3>
<p>Log correlation IDs, surface them in responses where safe, and monitor for retry amplification. An agent that misreads a 409 as “retry forever” becomes a denial-of-wallet attack on your own systems.</p>
<h2 id="heading-patterns-that-bridge-apis-and-agent-runtimes">Patterns That Bridge APIs and Agent Runtimes</h2>
<h3 id="heading-workflow-documentation-sequences-not-just-endpoints">Workflow Documentation: Sequences, Not Just Endpoints</h3>
<p>Agents excel when they can follow a recipe. Document common sequences (“create customer → add payment method → charge”) and consider standards meant for multi-step API flows (such as Arazzo) when your product’s complexity justifies it.</p>
<h3 id="heading-hypermedia-and-next-steps">Hypermedia and “Next Steps”</h3>
<p>Including links to plausible next actions (for example, pagination <code>next</code>, or related resources) reduces improvisation. This is the same spirit as <a href="https://en.wikipedia.org/wiki/HATEOAS">HATEOAS</a>: the response whispers what you can do next, instead of forcing the model to guess URLs.</p>
<h3 id="heading-tool-oriented-surfaces-for-example-mcp">Tool-Oriented Surfaces (For Example, MCP)</h3>
<p>Protocols like the Model Context Protocol (MCP) are gaining traction as a way to expose curated capabilities (“tools”) with schemas agents can bind to directly.</p>
<p>A common pragmatic pattern is not to dump every micro-endpoint as a tool, but to expose coarse-grained tools aligned to user outcomes while keeping your underlying REST API strict and clean.</p>
<p>MCP isn't a substitute for good API design. It's a delivery and discovery layer. Slapping a thin wrapper on a messy API still leaves you with a messy system – it just fails faster in public.</p>
<h3 id="heading-metadata-for-discovery-llmstxt-and-friends">Metadata for Discovery (<code>llms.txt</code> and Friends)</h3>
<p>Some teams publish <code>/llms.txt</code> or similar lightweight discovery files for documentation sites. Treat these as optional signposts, not replacements for OpenAPI.</p>
<p>Ecosystem adoption is still evolving, but the underlying idea is sound: make the canonical machine-readable description easy to find.</p>
<h2 id="heading-a-practical-beforeafter">A Practical Before/After</h2>
<h3 id="heading-weak-pattern-agent-hostile">Weak Pattern (Agent-hostile)</h3>
<pre><code class="language-http">POST /do-stuff
</code></pre>
<p>Response <code>200 OK</code>:</p>
<pre><code class="language-json">{ "ok": true }
</code></pre>
<p>Problems: no idempotency, no structured error, no entity ID, no way to poll, the agent must guess whether “ok” means “created” or “ignored duplicate.”</p>
<h3 id="heading-stronger-pattern-agent-friendly">Stronger Pattern (Agent-friendly)</h3>
<pre><code class="language-http">POST /v1/invoices
Idempotency-Key: 7b3c-...
</code></pre>
<p>Response <code>201 Created</code>:</p>
<pre><code class="language-json">{
  "invoice": {
    "id": "inv_9Qz",
    "status": "draft",
    "total": { "amount": "120.00", "currency": "USD" }
  },
  "links": {
    "finalize": "/v1/invoices/inv_9Qz/finalize"
  }
}
</code></pre>
<p>Conflict response <code>409 Conflict</code> with Problem Details:</p>
<pre><code class="language-json">{
  "type": "https://api.example.com/problems/duplicate-idempotency-key",
  "title": "Duplicate idempotency key",
  "status": 409,
  "detail": "A different request body was sent with the same Idempotency-Key.",
  "error_code": "IDEMPOTENCY_KEY_REUSE_BODY_MISMATCH"
}
</code></pre>
<p>This tells the agent what happened and whether retrying is appropriate.</p>
<h2 id="heading-checklist-is-your-api-agent-ready">Checklist: Is Your API Agent-Ready?</h2>
<ul>
<li><p><strong>Contract</strong>: Published OpenAPI 3.x, validated against real traffic, with rich descriptions and examples.</p>
</li>
<li><p><strong>Determinism</strong>: Documented state machines, consistent pagination, explicit async for long jobs.</p>
</li>
<li><p><strong>Safe writes</strong>: Idempotency for side effects, reconciliation endpoints where needed.</p>
</li>
<li><p><strong>Errors</strong>: Stable codes, structured bodies, documented remediation paths.</p>
</li>
<li><p><strong>Security</strong>: Least-privilege tokens, no “mystery” side doors agents can accidentally hit.</p>
</li>
<li><p><strong>Operations</strong>: Rate limits, bulk endpoints where appropriate, correlation IDs, dashboards for anomalous agent traffic.</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Designing for AI agents is, in most respects, disciplined API design — pushed to the level where machines can rely on your contract without tribal knowledge.</p>
<p>If you remember only three things:</p>
<ol>
<li><p><strong>Be predictable:</strong> in shapes, states, and side effects.</p>
</li>
<li><p><strong>Be explicit:</strong> in schemas, examples, and errors.</p>
</li>
<li><p><strong>Be protective:</strong> validate early, scope narrowly, and make dangerous actions hard to trigger by accident.</p>
</li>
</ol>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Bypass Cloud SMTP Restrictions Using Brevo and HTTP APIs ]]>
                </title>
                <description>
                    <![CDATA[ Being able to communicate by sending emails through web applications is important these days. It helps businesses stay connected with their potential customers, securely verify user identities, and de ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-bypass-cloud-smtp-restrictions-using-brevo-and-http-apis/</link>
                <guid isPermaLink="false">69fe2a2ef239332df4f7f8b9</guid>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ brevo ]]>
                    </category>
                
                    <category>
                        <![CDATA[ google smtp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloud-smtp ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Okoro Emmanuel Nzube ]]>
                </dc:creator>
                <pubDate>Fri, 08 May 2026 18:23:42 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/64f68792-e18c-4b90-9c65-c6d9884ab191.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Being able to communicate by sending emails through web applications is important these days. It helps businesses stay connected with their potential customers, securely verify user identities, and deliver crucial notifications like password resets.</p>
<p>But sometimes, deploying your perfectly working email function to the cloud leads to unexpected and frustrating errors. You build your backend, test it locally, and it works flawlessly. Then you deploy to the cloud, and suddenly your app stops sending emails completely.</p>
<p>In this article, you’ll learn exactly why your email setup fails on cloud platforms like Render or Heroku, the underlying networking rules causing the issue, and how to elegantly bypass these restrictions using Brevo's HTTP API.</p>
<p>Let’s dive right in.</p>
<h2 id="heading-outline">Outline</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-tools-well-be-using">Tools We'll Be Using</a></p>
</li>
<li><p><a href="#heading-the-problem-nodemailer-and-smtp-blocking">The Problem: Nodemailer and SMTP Blocking</a></p>
</li>
<li><p><a href="#heading-the-modern-trap-domain-verification">The "Modern" Trap: Domain Verification</a></p>
</li>
<li><p><a href="#heading-the-ultimate-solution-brevo-and-http-apis">The Ultimate Solution: Brevo and HTTP APIs</a></p>
</li>
<li><p><a href="#heading-backend-setup">Backend Setup</a></p>
</li>
<li><p><a href="#heading-brevo-configuration-setup">Brevo Configuration Setup</a></p>
</li>
<li><p><a href="#heading-creating-the-email-function">Creating the Email Function</a></p>
</li>
<li><p><a href="#heading-integrating-the-function-into-an-express-route">Integrating the Function into an Express Route</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To get the absolute most out of this tutorial, it’s important to have some basic knowledge of the following:</p>
<ul>
<li><p><strong>JavaScript and Node.js:</strong> Having a good fundamental understanding of how JS works on the server side will make it easier to follow along with the project.</p>
</li>
<li><p><strong>REST APIs:</strong> You should have a basic understanding of how HTTP requests (like POST and GET) work using native <code>fetch()</code> in Node.js.</p>
</li>
<li><p><strong>Express.js:</strong> A little background on creating basic server routes will be helpful, as we'll build a real-world controller.</p>
</li>
<li><p>A basic understanding of what <a href="https://nodemailer.com/">Nodemailer</a> is and how cloud hosting platforms (like Render or Heroku) operate.</p>
</li>
</ul>
<h2 id="heading-tools-well-be-using">Tools We’ll Be Using</h2>
<p>In one of my recent projects, I created a complex authentication flow where users needed an OTP (One Time Password) sent to their email to complete registration. I set up Nodemailer, linked my Gmail, and tested it on <code>localhost</code>. Within seconds, the emails arrived perfectly.</p>
<p>But when I deployed my backend to Render, the entire signup flow broke. After doing some deep digging, I found out why it broke and how to fix it permanently. And now that I know how it works, I wanted to share it with you all.</p>
<h2 id="heading-the-problem-nodemailer-and-smtp-blocking">The Problem: Nodemailer and SMTP Blocking</h2>
<p>So what exactly is the issue?</p>
<p>Nodemailer is a very popular Node.js module that lets you send emails efficiently. Usually, developers use it to connect to services like Gmail or Mailtrap using <strong>SMTP</strong> (Simple Mail Transfer Protocol). When your code tries to send an email, Nodemailer opens a connection to the mail server using Port <code>587</code> (for STARTTLS) or Port <code>465</code> (for SSL).</p>
<p>But cloud providers like Render, Heroku, DigitalOcean, and AWS face a massive daily battle against automated spammers. Malicious users often spin up thousands of free-tier servers specifically to blast out millions of spam emails. If a cloud provider allows this, their entire network IP address block will get blacklisted by Gmail, Outlook, and Yahoo.</p>
<p>To protect their network reputation, cloud providers enacted a heavy-handed, silent rule: <strong>All outbound traffic on Ports 25, 465, and 587 is strictly blocked on free and entry-level tiers.</strong></p>
<p>This means your server is literally trapped behind a firewall. If you check your server logs, you won't see an "Invalid Password" error. Instead, you'll see a timeout error that looks like this:</p>
<pre><code class="language-plaintext">Error: connect ETIMEDOUT 142.250.102.108:587
    at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1494:16)
</code></pre>
<p>Your code isn't broken – it's just being blocked at the network level!</p>
<h3 id="heading-the-modern-trap-domain-verification">The "Modern" Trap: Domain Verification</h3>
<p>When developers hit this wall, they often try modern API-based email services like Resend or SendGrid. These are amazing tools, but they introduce a new problem for beginners: <strong>Strict Domain Authentication.</strong></p>
<p>To use Resend in production, you must own a custom domain (like <code>yourname.com</code>) and configure DNS records (SPF, DKIM, and DMARC). If you don't own a domain, Resend's sandbox mode strictly restricts you to sending emails <em>only</em> to yourself. You can't send emails to your live users.</p>
<p>For a developer just trying to launch a portfolio project, buying a domain just to send test emails is a huge bottleneck.</p>
<h3 id="heading-the-ultimate-solution-brevo-and-http-apis">The Ultimate Solution: Brevo and HTTP APIs</h3>
<p>We need a solution that meets two criteria:</p>
<ol>
<li><p>It must bypass the Port <code>587</code> firewall.</p>
</li>
<li><p>It must let us send emails to <em>anyone</em> without forcing us to buy a custom domain.</p>
</li>
</ol>
<p>This is where the architectural difference between SMTP and REST APIs comes to the rescue. While SMTP is a dedicated protocol for routing mail, a REST API operates over standard web traffic using <strong>HTTPS (Port 443)</strong>. Cloud providers <em>can't</em> block Port 443, because doing so would prevent your server from fetching data from databases or functioning as a web server entirely.</p>
<p>Enter <strong>Brevo</strong> (formerly Sendinblue). Brevo is a powerful email platform that allows you to send emails via a standard REST API. Best of all, their free tier (300 emails/day) allows Single Sender Verification. You just verify your standard Gmail address, and they let you send to anyone!</p>
<p>By sending a JSON payload via HTTPS to Brevo's API, your server routes the traffic out of the unrestricted Port <code>443</code>, bypassing the Render firewall completely.</p>
<p>Now that you know the theory behind the tools we’ll be using, let’s move on to writing the code.</p>
<h2 id="heading-backend-setup">Backend Setup</h2>
<p>First things first, you have to set up your environment. If you don't already have Node.js installed on your computer, head to their <a href="https://nodejs.org/en">website</a> to download and install it.</p>
<p>Start by running <code>npm init -y</code> in your terminal. This creates the <code>package.json</code> file which manages your project and stores all the dependencies.</p>
<p>Next, run <code>npm install express dotenv</code>.</p>
<p>You might be used to installing <code>nodemailer</code> for your email tasks. But because we are going to use the native Node.js <code>fetch()</code> API to talk to the Brevo API, you actually don't need to install <em>any</em> heavy email libraries at all! We want to keep our backend as lightweight as possible.</p>
<h3 id="heading-brevo-configuration-setup">Brevo Configuration Setup</h3>
<p>Before you write the email function, you first need to configure Brevo to get access to your API key.</p>
<ol>
<li><p>Go to <a href="https://www.brevo.com/">Brevo.com</a> and create a free account.</p>
</li>
<li><p>During setup, they will ask you to add a <strong>Sender Email</strong>. Make sure you input your standard Gmail address. They will send you an email with a link to verify you own this address.</p>
</li>
<li><p>Once verified and inside the dashboard, click on your profile name in the top right corner, and select <strong>SMTP &amp; API</strong> from the dropdown menu.</p>
</li>
<li><p>Go to the <strong>API Keys</strong> tab and click <strong>Generate a new API key</strong>. Give it a name like "MyWebApp".</p>
</li>
</ol>
<p>Copy this generated key and store it safely in a <code>.env</code> file at the root of your project:</p>
<pre><code class="language-env"># .env file
EMAIL_USER = yourverifiedemail@gmail.com
BREVO_API_KEY = xkeysib-your-generated-api-key-goes-here
</code></pre>
<h3 id="heading-creating-the-email-function">Creating the Email Function</h3>
<p>Now that you’ve gotten your API key and set up your environment variables, all that remains is to start putting your backend code together.</p>
<p>Create a file named <code>utils/email.js</code>.</p>
<p>First, start by ensuring you can load your <code>.env</code> file so you can easily access the credentials you generated:</p>
<pre><code class="language-javascript">require("dotenv").config();

// We'll define the function to accept dynamic options
const sendEmail = async (options) =&gt; {
  const brevoApiKey = process.env.BREVO_API_KEY;
  const senderEmail = process.env.EMAIL_USER;

  // Validate that the keys actually exist
  if (!brevoApiKey || !senderEmail) {
    throw new Error("Missing Brevo credentials in environment variables.");
  }
</code></pre>
<p>Next on the line, you’ll need to structure your payload. This is the JSON object that tells Brevo exactly who is sending the email, who is receiving it, and what the content is. Here’s how you can do that:</p>
<pre><code class="language-javascript">  const payload = {
    sender: {
      name: "My Awesome Web App",
      email: senderEmail, // Must match your verified Brevo email
    },
    to: [
      {
        email: options.email, // The dynamic email address of the user receiving the email
      },
    ],
    subject: options.subject,
    htmlContent: options.html,
  };
</code></pre>
<p>In the code above, the <code>payload</code> object securely packages up your information. We pass in <code>options.email</code>, <code>options.subject</code>, and <code>options.html</code> so that we can reuse this single function for welcome emails, password resets, and notifications.</p>
<p>Now, create the actual network request that sends your data to the Brevo backend. We'll use the <code>POST</code> method. When the data is sent, it must be stringified into a JSON format.</p>
<pre><code class="language-javascript">  try {
    const response = await fetch("https://api.brevo.com/v3/smtp/email", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "api-key": brevoApiKey,
      },
      body: JSON.stringify(payload),
    });

    const result = await response.json();

    if (!response.ok) {
      throw new Error(`Brevo API Error: ${JSON.stringify(result)}`);
    }

    console.log(`Email successfully sent to ${options.email} via Brevo HTTP API!`);
  } catch (error) {
    console.error("Error details:", error.message);
  }
};

module.exports = sendEmail;
</code></pre>
<p>In the code above, after the payload is submitted, if the message is sent successfully, a success log will be displayed in your terminal. But if the message wasn’t successful – maybe due to a typo in your API key – an error message will be thrown to help you debug exactly what went wrong.</p>
<h3 id="heading-integrating-the-function-into-an-express-route">Integrating the Function into an Express Route</h3>
<p>At this point, you've successfully built a robust email function. Let's see how you would actually use this in a real Express application.</p>
<p>Create an <code>index.js</code> file and set up a simple Express server route:</p>
<pre><code class="language-javascript">const express = require("express");
const sendEmail = require("./utils/email");
const app = express();

app.use(express.json()); // Middleware to parse JSON request bodies

app.post("/api/signup", async (req, res) =&gt; {
  const { username, email } = req.body;

  // 1. Save user to database (skipped for brevity)
  
  // 2. Generate a random OTP
  const otp = Math.floor(100000 + Math.random() * 900000);

  // 3. Send the email using our new Brevo function
  try {
    await sendEmail({
      email: email,
      subject: "Welcome! Here is your Verification Code",
      html: `
        &lt;div style="font-family: sans-serif; text-align: center;"&gt;
          &lt;h2&gt;Welcome to My Awesome Web App, ${username}!&lt;/h2&gt;
          &lt;p&gt;Please use the verification code below to complete your registration:&lt;/p&gt;
          &lt;h1 style="color: #2563eb; letter-spacing: 5px;"&gt;${otp}&lt;/h1&gt;
          &lt;p&gt;This code will expire in 10 minutes.&lt;/p&gt;
        &lt;/div&gt;
      `,
    });

    res.status(201).json({ message: "User created and email sent!" });
  } catch (error) {
    res.status(500).json({ error: "Failed to send email." });
  }
});

app.listen(8000, () =&gt; {
  console.log("Server running on port 8000");
});
</code></pre>
<p>And that is it! You can now hit this <code>/api/signup</code> endpoint from your React or Vue frontend, and it will instantly fire off a beautifully formatted email via Brevo's REST API.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>As developers, encountering a bug that works locally but fails in production is a rite of passage. But the "Email Delivery Failed" timeout error is special. It teaches you that software engineering isn't just about writing clean syntax – it's about understanding the underlying infrastructure, network layers, and the security context of the environment your code runs in.</p>
<p>By swapping a protocol (SMTP) for an architectural pattern (REST API over HTTPS), you didn't just fix a bug. You successfully engineered a secure, free, and robust bypass around a cloud-level firewall without relying on heavy third-party NPM modules like Nodemailer.</p>
<p>If you've made it this far, I hope I've successfully shown you the importance of understanding network layers and how you can use HTTP APIs to send email messages directly from your web applications safely.</p>
<p>Thank you for reading!</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 Implement Token Bucket Rate Limiting with FastAPI ]]>
                </title>
                <description>
                    <![CDATA[ APIs power everything from mobile apps to enterprise platforms, quietly handling millions of requests per day. Without safeguards, a single misconfigured client or a burst of automated traffic can ove ]]>
                </description>
                <link>https://www.freecodecamp.org/news/token-bucket-rate-limiting-fastapi/</link>
                <guid isPermaLink="false">69c6f8747cf270651055571c</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ratelimit ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Prosper Ugbovo ]]>
                </dc:creator>
                <pubDate>Fri, 27 Mar 2026 21:36:52 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/fba3d4a6-faca-429a-8e16-a3e9778d2cf8.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>APIs power everything from mobile apps to enterprise platforms, quietly handling millions of requests per day. Without safeguards, a single misconfigured client or a burst of automated traffic can overwhelm your service, degrading performance for everyone.</p>
<p>Rate limiting prevents this. It controls how many requests a client can make within a given timeframe, protecting your infrastructure from both intentional abuse and accidental overload.</p>
<p>Among the several algorithms used for rate limiting, the <strong>Token Bucket</strong> stands out for its balance of simplicity and flexibility. Unlike fixed window counters that reset abruptly, the Token Bucket allows short bursts of traffic while still enforcing a sustainable long-term rate. This makes it a practical choice for APIs where clients occasionally need to send a quick flurry of requests without being penalized.</p>
<p>In this guide, you'll implement a Token Bucket rate limiter in a FastAPI application. You'll build the algorithm from scratch as a Python class, wire it into FastAPI as middleware with per-user tracking, add standard rate limit headers to your responses, and test everything with a simple script. By the end, you'll have a working rate limiter you can drop into any FastAPI project.</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-understanding-the-token-bucket-algorithm">Understanding the Token Bucket Algorithm</a></p>
</li>
<li><p><a href="#heading-setting-up-the-fastapi-project">Setting Up the FastAPI Project</a></p>
</li>
<li><p><a href="#heading-implementing-the-token-bucket-class">Implementing the Token Bucket Class</a></p>
</li>
<li><p><a href="#heading-adding-peruser-rate-limiting-middleware">Adding Per-User Rate Limiting Middleware</a></p>
</li>
<li><p><a href="#heading-testing-the-rate-limiter">Testing the Rate Limiter</a></p>
</li>
<li><p><a href="#heading-where-rate-limiting-fits-in-your-architecture">Where Rate Limiting Fits in Your Architecture</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow this tutorial, you'll need:</p>
<ul>
<li><p><strong>Python 3.9 or later</strong> installed on your machine. You can verify your version by running <code>python --version</code>.</p>
</li>
<li><p><strong>Familiarity with Python</strong> and basic knowledge of how HTTP APIs work.</p>
</li>
<li><p><strong>A text editor</strong> such as VS Code, Vim, or any editor you prefer.</p>
</li>
</ul>
<h2 id="heading-understanding-the-token-bucket-algorithm">Understanding the Token Bucket Algorithm</h2>
<p>Before writing code, it helps to understand the mechanism you'll be building.</p>
<p>The Token Bucket algorithm models rate limiting with two simple concepts: a <strong>bucket</strong> that holds tokens, and a <strong>refill process</strong> that adds tokens at a steady rate.</p>
<p>Here is how it works:</p>
<ol>
<li><p>The bucket starts full, holding a fixed maximum number of tokens (the capacity).</p>
</li>
<li><p>Each incoming request costs one token. If the bucket has tokens available, the request is allowed, and one token is removed.</p>
</li>
<li><p>If the bucket is empty, the request is rejected with a <code>429 Too Many Requests</code> response.</p>
</li>
<li><p>Tokens are added back to the bucket at a constant refill rate, regardless of whether requests are coming in. The bucket never exceeds its maximum capacity.</p>
</li>
</ol>
<p>The capacity determines how large a burst the system absorbs. The refill rate defines the sustained throughput. For example, a bucket with a capacity of 10 and a refill rate of 2 tokens per second allows a client to fire 10 requests instantly, but after that, they can only make 2 requests per second until the bucket refills.</p>
<p>This two-parameter design gives you precise control:</p>
<table>
<thead>
<tr>
<th>Parameter</th>
<th>Controls</th>
<th>Example</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Capacity</strong> (max tokens)</td>
<td>Maximum burst size</td>
<td>10 tokens = 10 requests at once</td>
</tr>
<tr>
<td><strong>Refill rate</strong></td>
<td>Sustained throughput</td>
<td>2 tokens/sec = 2 requests/sec long-term</td>
</tr>
<tr>
<td><strong>Refill interval</strong></td>
<td>Granularity of refill</td>
<td>1.0 sec = tokens added every second</td>
</tr>
</tbody></table>
<p>Compared to other rate-limiting algorithms:</p>
<ul>
<li><p><strong>Fixed Window</strong> counters reset at hard boundaries (for example, every minute), which can allow double the intended rate at window edges. The Token Bucket has no such boundary.</p>
</li>
<li><p><strong>Sliding Window</strong> counters are more accurate but more complex to implement and maintain.</p>
</li>
<li><p><strong>Leaky Bucket</strong> processes requests at a fixed rate and queues the rest. The Token Bucket is similar, but allows bursts instead of forcing a constant pace.</p>
</li>
</ul>
<p>The Token Bucket is widely used in production systems. AWS API Gateway, NGINX, and Stripe all use variations of it.</p>
<h2 id="heading-setting-up-the-fastapi-project">Setting Up the FastAPI Project</h2>
<p>Create a project directory and install the dependencies:</p>
<pre><code class="language-shell">mkdir fastapi-ratelimit &amp;&amp; cd fastapi-ratelimit
</code></pre>
<p>Create and activate a virtual environment:</p>
<pre><code class="language-shell">python -m venv venv
</code></pre>
<p>On Linux/macOS:</p>
<pre><code class="language-shell">source venv/bin/activate
</code></pre>
<p>On Windows:</p>
<pre><code class="language-shell">venv\Scripts\activate
</code></pre>
<p>Install FastAPI and Uvicorn:</p>
<pre><code class="language-shell">pip install fastapi uvicorn
</code></pre>
<p>Create the project file structure:</p>
<pre><code class="language-plaintext">fastapi-ratelimit/
├── main.py
└── ratelimiter.py
</code></pre>
<p>Create <code>main.py</code> with a minimal FastAPI application:</p>
<pre><code class="language-python">from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def root():
    return {"message": "Hello, world!"}
</code></pre>
<p>Start the server to verify the setup:</p>
<pre><code class="language-shell">uvicorn main:app --reload
</code></pre>
<p>You should see output similar to:</p>
<pre><code class="language-plaintext">INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process
</code></pre>
<p>Open in your browser <a href="http://127.0.0.1:8000">http://127.0.0.1:8000</a> or run curl <a href="http://127.0.0.1:8000">http://127.0.0.1:8000</a>. You should receive:</p>
<pre><code class="language-json">{"message": "Hello, world!"}
</code></pre>
<p>With the project running, you can move on to building the rate limiter.</p>
<h2 id="heading-implementing-the-token-bucket-class">Implementing the Token Bucket Class</h2>
<p>Open <code>ratelimiter.py</code> in your editor and add the following code. This class implements the Token Bucket algorithm with thread-safe operations:</p>
<pre><code class="language-python">import time
import threading


class TokenBucket:
    """
    Token Bucket rate limiter.

    Each bucket starts full at `max_tokens` and refills `refill_rate`
    tokens every `interval` seconds, up to the maximum capacity.
    """

    def __init__(self, max_tokens: int, refill_rate: int, interval: float):
        """
        Initialize a new Token Bucket.

        :param max_tokens: Maximum number of tokens the bucket can hold (burst capacity).
        :param refill_rate: Number of tokens added per refill interval.
        :param interval: Time in seconds between refills.
        """
        assert max_tokens &gt; 0, "max_tokens must be positive"
        assert refill_rate &gt; 0, "refill_rate must be positive"
        assert interval &gt; 0, "interval must be positive"

        self.max_tokens = max_tokens
        self.refill_rate = refill_rate
        self.interval = interval

        self.tokens = max_tokens
        self.refilled_at = time.time()
        self.lock = threading.Lock()

    def _refill(self):
        """Add tokens based on elapsed time since the last refill."""
        now = time.time()
        elapsed = now - self.refilled_at

        if elapsed &gt;= self.interval:
            num_refills = int(elapsed // self.interval)
            self.tokens = min(
                self.max_tokens,
                self.tokens + num_refills * self.refill_rate
            )
            # Advance the timestamp by the number of full intervals consumed,
            # not to `now`, so partial intervals aren't lost.
            self.refilled_at += num_refills * self.interval

    def allow_request(self, tokens: int = 1) -&gt; bool:
        """
        Attempt to consume `tokens` from the bucket.

        Returns True if the request is allowed, False if the bucket
        does not have enough tokens.
        """
        with self.lock:
            self._refill()

            if self.tokens &gt;= tokens:
                self.tokens -= tokens
                return True
            return False

    def get_remaining(self) -&gt; int:
        """Return the current number of available tokens."""
        with self.lock:
            self._refill()
            return self.tokens

    def get_reset_time(self) -&gt; float:
        """Return the Unix timestamp when the next refill occurs."""
        with self.lock:
            return self.refilled_at + self.interval
</code></pre>
<p>The class has three public methods:</p>
<ul>
<li><p><code>allow_request()</code> is the core method. It refills tokens based on elapsed time, then tries to consume one. It returns <code>True</code> if the request is allowed, <code>False</code> if the bucket is empty.</p>
</li>
<li><p><code>get_remaining()</code> returns the number of tokens the client has left. You will use this for response headers.</p>
</li>
<li><p><code>get_reset_time()</code> returns when the next token will be added. This is also exposed in response headers.</p>
</li>
</ul>
<p>The <code>threading.Lock</code> ensures that concurrent requests don't create race conditions when reading or modifying the token count. This is important because FastAPI runs request handlers concurrently.</p>
<p><strong>Note:</strong> This implementation stores bucket state in memory. If you restart the server, all buckets reset. For persistence across restarts or multiple server instances, you would store token counts in Redis or a similar external store. The in-memory approach is sufficient for single-instance deployments.</p>
<h2 id="heading-adding-per-user-rate-limiting-middleware">Adding Per-User Rate Limiting Middleware</h2>
<p>A single global bucket would throttle all users together. One heavy user could exhaust the limit for everyone. Instead, you'll assign a separate bucket to each user, identified by their IP address.</p>
<p>Add the following to <code>ratelimiter.py</code>, below the <code>TokenBucket</code> class:</p>
<pre><code class="language-python">from collections import defaultdict


class RateLimiterStore:
    """
    Manages per-user Token Buckets.

    Each unique client key (e.g., IP address) gets its own bucket
    with identical parameters.
    """

    def __init__(self, max_tokens: int, refill_rate: int, interval: float):
        self.max_tokens = max_tokens
        self.refill_rate = refill_rate
        self.interval = interval
        self._buckets: dict[str, TokenBucket] = {}
        self._lock = threading.Lock()

    def get_bucket(self, key: str) -&gt; TokenBucket:
        """
        Return the TokenBucket for a given client key.
        Creates a new bucket if one does not exist yet.
        """
        with self._lock:
            if key not in self._buckets:
                self._buckets[key] = TokenBucket(
                    max_tokens=self.max_tokens,
                    refill_rate=self.refill_rate,
                    interval=self.interval,
                )
            return self._buckets[key]
</code></pre>
<p>Now open <code>main.py</code> and replace its contents with the full application, including the rate-limiting middleware:</p>
<pre><code class="language-python">import time

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

from ratelimiter import RateLimiterStore

app = FastAPI()

# Configure rate limits: 10 requests burst, 2 tokens added every 1 second.
limiter = RateLimiterStore(max_tokens=10, refill_rate=2, interval=1.0)


@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
    """
    Middleware that enforces per-IP rate limiting on every request.
    Adds standard rate limit headers to every response.
    """
    # Identify the client by IP address.
    client_ip = request.client.host
    bucket = limiter.get_bucket(client_ip)

    # Check if the client has tokens available.
    if not bucket.allow_request():
        retry_after = bucket.get_reset_time() - time.time()
        return JSONResponse(
            status_code=429,
            content={"detail": "Too many requests. Try again later."},
            headers={
                "Retry-After": str(max(1, int(retry_after))),
                "X-RateLimit-Limit": str(bucket.max_tokens),
                "X-RateLimit-Remaining": str(bucket.get_remaining()),
                "X-RateLimit-Reset": str(int(bucket.get_reset_time())),
            },
        )

    # Request is allowed. Process it and add rate limit headers to the response.
    response = await call_next(request)
    response.headers["X-RateLimit-Limit"] = str(bucket.max_tokens)
    response.headers["X-RateLimit-Remaining"] = str(bucket.get_remaining())
    response.headers["X-RateLimit-Reset"] = str(int(bucket.get_reset_time()))
    return response


@app.get("/")
async def root():
    return {"message": "Hello, world!"}


@app.get("/data")
async def get_data():
    return {"data": "Some important information"}


@app.get("/health")
async def health():
    return {"status": "ok"}
</code></pre>
<p>The middleware does the following on every incoming request:</p>
<ol>
<li><p>Extracts the client's IP address from <code>request.client.host</code>.</p>
</li>
<li><p>Retrieves (or creates) that client's Token Bucket from the store.</p>
</li>
<li><p>Calls <code>allow_request()</code>. If the bucket is empty, it returns a <code>429</code> response with a <code>Retry-After</code> header telling the client how long to wait.</p>
</li>
<li><p>If tokens are available, it processes the request normally and attaches rate limit headers to the response.</p>
</li>
</ol>
<p>The three <code>X-RateLimit-*</code> headers follow a <a href="https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/">widely adopted convention</a>:</p>
<table>
<thead>
<tr>
<th>Header</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td><code>X-RateLimit-Limit</code></td>
<td>Maximum burst capacity (max tokens)</td>
</tr>
<tr>
<td><code>X-RateLimit-Remaining</code></td>
<td>Tokens left in the current bucket</td>
</tr>
<tr>
<td><code>X-RateLimit-Reset</code></td>
<td>Unix timestamp when the next refill occurs</td>
</tr>
</tbody></table>
<p>These headers allow well-behaved clients to self-throttle before hitting the limit.</p>
<h2 id="heading-testing-the-rate-limiter">Testing the Rate Limiter</h2>
<p>Restart the server if it's not already running:</p>
<pre><code class="language-shell">uvicorn main:app --reload
</code></pre>
<h3 id="heading-manual-testing-with-curl">Manual Testing with curl</h3>
<p>Manual testing with <code>curl</code> is useful during development when you want to quickly verify that your middleware is working. A single request lets you confirm that the rate limit headers are present, the values are correct, and one token is consumed as expected.</p>
<p>This approach is fast and requires no additional setup, making it ideal for spot-checking your configuration after making changes.</p>
<p>Send a single request and inspect the response:</p>
<pre><code class="language-shell">curl -i http://127.0.0.1:8000/data
</code></pre>
<p>You should see a <code>200</code> response with headers like:</p>
<pre><code class="language-plaintext">HTTP/1.1 200 OK
x-ratelimit-limit: 10
x-ratelimit-remaining: 9
x-ratelimit-reset: 1739836801
</code></pre>
<h3 id="heading-automated-burst-test">Automated Burst Test</h3>
<p>While <code>curl</code> confirms that the rate limiter is active, it can't verify that the limiter actually blocks requests when the bucket is empty. For that, you need to send requests faster than the refill rate and observe the <code>429</code> responses. An automated burst test is essential before deploying to production, after changing your bucket parameters, or when you need to verify both the blocking and refill behavior.</p>
<p>Create a file called <code>test_ratelimit.py</code> in your project directory:</p>
<pre><code class="language-python">import requests
import time


def test_burst():
    """Send 15 rapid requests to trigger the rate limit."""
    url = "http://127.0.0.1:8000/data"
    results = []

    for i in range(15):
        response = requests.get(url)
        remaining = response.headers.get("X-RateLimit-Remaining", "N/A")
        results.append((i + 1, response.status_code, remaining))
        print(f"Request {i+1:2d} | Status: {response.status_code} | Remaining: {remaining}")

    print()

    allowed = sum(1 for _, status, _ in results if status == 200)
    blocked = sum(1 for _, status, _ in results if status == 429)
    print(f"Allowed: {allowed}, Blocked: {blocked}")


def test_refill():
    """Exhaust tokens, wait for a refill, then confirm requests succeed again."""
    url = "http://127.0.0.1:8000/data"

    print("\n--- Exhausting tokens ---")
    for i in range(12):
        response = requests.get(url)
        print(f"Request {i+1:2d} | Status: {response.status_code}")

    print("\n--- Waiting 3 seconds for refill ---")
    time.sleep(3)

    print("\n--- Sending requests after refill ---")
    for i in range(5):
        response = requests.get(url)
        remaining = response.headers.get("X-RateLimit-Remaining", "N/A")
        print(f"Request {i+1:2d} | Status: {response.status_code} | Remaining: {remaining}")


if __name__ == "__main__":
    print("=== Burst Test ===")
    test_burst()

    # Allow bucket to refill before next test
    time.sleep(6)

    print("\n=== Refill Test ===")
    test_refill()
</code></pre>
<p>Install the <code>requests</code> library if you don't have it:</p>
<pre><code class="language-shell">pip install requests
</code></pre>
<p>Run the test:</p>
<pre><code class="language-shell">python test_ratelimit.py
</code></pre>
<p>You should see output similar to:</p>
<pre><code class="language-output">=== Burst Test ===
Request  1 | Status: 200 | Remaining: 9
Request  2 | Status: 200 | Remaining: 8
Request  3 | Status: 200 | Remaining: 7
...
Request 10 | Status: 200 | Remaining: 0
Request 11 | Status: 429 | Remaining: 0
Request 12 | Status: 429 | Remaining: 0
...
Request 15 | Status: 429 | Remaining: 0

Allowed: 10, Blocked: 5
</code></pre>
<p>The first 10 requests succeed (one token each from the full bucket). Requests 11 through 15 are rejected because the bucket is empty. The refill test then confirms that after waiting, tokens reappear and requests succeed again.</p>
<p><strong>Note:</strong> The exact split between allowed and blocked requests may vary slightly due to timing. Tokens may refill between rapid requests. This is expected behavior.</p>
<h2 id="heading-where-rate-limiting-fits-in-your-architecture">Where Rate Limiting Fits in Your Architecture</h2>
<p>The implementation in this tutorial runs inside your application process, which is the simplest approach and works well for single-instance deployments. In larger systems, rate limiting typically appears at multiple layers:</p>
<ul>
<li><p><strong>API gateway level</strong> (NGINX, Kong, Traefik, Envoy): A coarse global rate limit applied to all traffic before it reaches your application. This protects against large-scale abuse and DDoS.</p>
</li>
<li><p><strong>Application level</strong> (this tutorial): Fine-grained per-user or per-endpoint limits inside your service. This is useful for enforcing different quotas on different API tiers.</p>
</li>
<li><p><strong>Both</strong>: Many production systems combine a gateway-level global limiter with an in-app per-user limiter. The gateway catches the flood and the application enforces business rules.</p>
</li>
</ul>
<p>For multi-instance deployments (multiple server processes behind a load balancer), the in-memory <code>RateLimiterStore</code> won't share state across instances. In that case, replace the in-memory dictionary with Redis. The Token Bucket logic stays the same – only the storage layer changes.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this guide, you built a Token Bucket rate limiter from scratch and integrated it into a FastAPI application with per-user tracking and standard rate limit response headers. You also tested the implementation to verify that burst capacity and refill behavior work as expected.</p>
<p>The Token Bucket algorithm gives you two straightforward controls, capacity for burst tolerance and refill rate for sustained throughput, which cover the vast majority of rate-limiting needs.</p>
<p>From here, you can extend this foundation by:</p>
<ul>
<li><p>Replacing the in-memory store with Redis for multi-instance deployments.</p>
</li>
<li><p>Applying different rate limits per endpoint by creating separate <code>RateLimiterStore</code> instances.</p>
</li>
<li><p>Using authenticated user IDs instead of IP addresses for more accurate client identification.</p>
</li>
<li><p>Adding metrics and logging to track how often clients are being throttled.</p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Voice-Powered AI Application with the Web Speech API ]]>
                </title>
                <description>
                    <![CDATA[ The Web Speech API is a web browser API that enables web applications to use sound as data in their operations. With the API, web apps can transcribe the speech in sound input and also synthesise spee ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-voice-powered-ai-application-with-the-web-speech-api/</link>
                <guid isPermaLink="false">69c5a2af10e664c5da34709b</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Orim Dominic Adah ]]>
                </dc:creator>
                <pubDate>Thu, 26 Mar 2026 21:18:39 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/d6c77704-8ad6-4852-8a10-6656c76a34f4.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The <a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API">Web Speech API</a> is a web browser API that enables web applications to use sound as data in their operations. With the API, web apps can transcribe the speech in sound input and also synthesise speech from text.</p>
<p>This guide shows you how to build a full-stack web application that:</p>
<ul>
<li><p>Accepts audio input and transcribes the speech in it</p>
</li>
<li><p>Prompts an AI agent with the transcription</p>
</li>
<li><p>Displays the AI response on the UI</p>
</li>
</ul>
<p>The application you'll build will be a simplified version of the <strong>Use Voice</strong> feature on AI chat applications highlighted in the image below:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e28b713f978a0e2cd2b763/7adc60ff-cedb-48bc-a5c9-6e913dd3cc60.png" alt="Use voice feature of AI chat applications" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>By practising along with this article, you'll learn how to:</p>
<ul>
<li><p>Build a frontend application that uses the <a href="https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition">SpeechRecognition</a> API to accept voice input and transcribe it</p>
</li>
<li><p>Build a backend app that prompts an AI assistant of your choice and sends a response back to clients</p>
</li>
<li><p>Connect both applications together to send the transcription to the backend as a prompt and display the AI response on the frontend</p>
</li>
</ul>
<p>Optionally, you'll also learn how to host the frontend with Firebase and the backend with Google Cloud Run.</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-the-web-speech-api">The Web Speech API</a></p>
<ul>
<li><a href="#heading-how-to-use-the-web-speech-api-in-javascript-for-seo">How to Use the Web Speech API in JavaScript for SEO</a></li>
</ul>
</li>
<li><p><a href="#heading-how-the-application-works">How the Application Works</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-application">How to Build the Application</a></p>
<ul>
<li><p><a href="#heading-create-the-backend-application-with-nodejs">Create the Backend Application with Node.js</a></p>
</li>
<li><p><a href="#heading-integrate-an-ai-assistant-into-the-nodejs-application">Integrate an AI Assistant into the Node.js Application</a></p>
</li>
<li><p><a href="#heading-create-the-frontend-application-with-vite">Create the Frontend Application with Vite</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-test-the-application-locally">Test the Application Locally</a></p>
</li>
<li><p><a href="#heading-deploy-the-backend-application-with-google-cloud-run">Deploy the Backend Application with Google Cloud Run</a></p>
</li>
<li><p><a href="#heading-deploy-the-frontend-application-with-firebase">Deploy the Frontend Application with Firebase</a></p>
</li>
<li><p><a href="#heading-connect-the-deployed-applications">Connect the Deployed Applications</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This guide assumes that you have a working knowledge of HTML, CSS, and JavaScript in the browser. Basic familiarity with Node.js is beneficial but not essential.</p>
<p>In addition, you should have:</p>
<ul>
<li><p>Google Chrome (at least version 33 ) and a functional audio input device</p>
</li>
<li><p><a href="https://nodejs.org/">Node.js</a>&nbsp;and npm installed on your computer</p>
</li>
<li><p>An API key from any AI assistant of your choice</p>
</li>
<li><p>A Google Cloud account and a Firebase account if you intend to deploy the applications</p>
</li>
</ul>
<h2 id="heading-the-web-speech-api">The Web Speech API</h2>
<p>The Web Speech API enables applications to transcribe the speech in audio input and also synthesise audio from text. The API is made up of two components:</p>
<ul>
<li><p>The <a href="https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition">SpeechRecognition</a> component which receives audio input, recognises speech in the input and transcribes it</p>
</li>
<li><p>The <a href="https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesis">SpeechSynthesis</a> component which synthesises speech from text</p>
</li>
</ul>
<p>You'll use the <code>SpeechRecognition</code> component in this guide.</p>
<h3 id="heading-how-to-use-the-web-speech-api-in-javascript-for-seo">How to Use the Web Speech API in JavaScript for SEO</h3>
<p>The <code>SpeechRecognition</code> component works through a JavaScript object instantiated in code.</p>
<pre><code class="language-javascript">const recognition = new SpeechRecognition();
</code></pre>
<p>The <code>recognition</code> instance exposes several event listeners that respond to audio input. For example, the <code>audiostart</code> event fires when sound is first detected, logging <code>"audio detected"</code> to the console as shown in the snippet below.</p>
<pre><code class="language-javascript">recognition.addEventListener("audiostart", function(event){
  console.log("audio detected")
}
</code></pre>
<p>The first time it recognises speech in a sound bite, the <code>speechstart</code> event is fired.</p>
<p>A <code>SpeechRecognition</code> instance also has the ability to configure how speech recognition should work. For example, it has a property called <code>lang</code> which sets the language that it should recognise. The default value of the <code>lang</code> property is the HTML <code>lang</code> attribute value, or the browser's language setting. It also has a boolean property called <code>interimResults</code>, which when set to true, enables the instance to return transcriptions incrementally rather than waiting for the audio input to end.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e28b713f978a0e2cd2b763/d3e7b39a-fcf2-4624-81ca-4667346c8269.png" alt="How speech is converted to transcripts via the Web Speech API" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Audio captured by the microphone is processed by a recognition engine which could be in a remote server (for Google Chrome) or embedded in the browser (for Firefox).</p>
<p>After processing, the recognition engine returns a result, which is a list of words or phrases that have been recognised in the speech.</p>
<p>Each transcription in the list has two properties: <code>confidence</code>, a numerical estimate of its accuracy ranging from 0 (low) to 1 (high), and <code>transcript</code>, the recognised text for all or part of the speech.</p>
<h2 id="heading-how-the-application-works">How the Application Works</h2>
<p>In order for a <code>SpeechRecognition</code> instance to capture audio, it needs access to the microphone. The browser requests permission to use the microphone and, if granted, the application uses it to capture audio for the instance.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e28b713f978a0e2cd2b763/e2847268-dfca-4f81-b929-7cc8ebd57eee.png" alt="Architecture diagram showing how Web Speech API sends transcription to a Node.js backend" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Speech captured by the instance goes through the recognition engine and produces results or transcriptions. Results with high confidence are combined and sent to the backend via an API request.</p>
<p>The backend uses the transcript it receives to prompt an AI assistant. The response from the AI assistant is sent back to the frontend and displayed on the UI as shown in the screenshot below:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e28b713f978a0e2cd2b763/0cd3eb46-595b-4193-82a0-874e8b9f9652.png" alt="Sample image of the voice-powered application built in this guide" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<h2 id="heading-how-to-build-the-application">How to Build the Application</h2>
<p>First, you'll build a Node.js backend application that:</p>
<ul>
<li><p>Receives a text prompt from the frontend</p>
</li>
<li><p>Sends the prompt to an AI assistant and receives a response</p>
</li>
<li><p>Returns the response of the AI assistant to the frontend</p>
</li>
</ul>
<p>Next, you'll build the frontend to:</p>
<ul>
<li><p>Accept your speech prompt, transcribe it, and display the transcription</p>
</li>
<li><p>Send the transcription result to the backend</p>
</li>
<li><p>Receive, format and display the response from the backend</p>
</li>
</ul>
<p>Optionally, you'll deploy the frontend to Firebase and the backend to Google Cloud Run, connecting them so the application is publicly accessible.</p>
<h3 id="heading-create-the-backend-application-with-nodejs">Create the Backend Application with Node.js</h3>
<p>The backend application you'll build in this section will receive text prompt from clients and use it to prompt an AI assistant. After receiving a response from the AI assistant, it will send the response back to the client.</p>
<p>We'll use Gemini in this guide, but you can use any AI assistant of your choice.</p>
<ol>
<li><p>Create a folder for the backend app and give it a name, for example, "server".</p>
</li>
<li><p>In terminal, navigate to the project folder, run the <code>npm init</code> command, and answer the follow-up questions to generate a <code>package.json</code> file</p>
</li>
<li><p>In the root of the project, create a file named <code>index.js</code>.</p>
</li>
</ol>
<p>Your project folder should have a structure like this:</p>
<pre><code class="language-plaintext">├── index.js
├── package.json
</code></pre>
<p>The <code>package.json</code> file should have the following values for <code>main</code> , <code>type</code> and <code>scripts.start</code>:</p>
<pre><code class="language-json"> { 
    "main": "index.js", 
    "type": "module", 
    "scripts": { 
       "start": "node index.js" 
    }, 
}  
</code></pre>
<ol>
<li>Copy and paste the code below into the <code>index.js</code> file to set up the server:</li>
</ol>
<pre><code class="language-javascript">import http from "node:http";

async function parseRequestBody(req) { 
    return new Promise((resolve, reject) =&gt; { 
        let data = ""; 
        req.on("data", (chunk) =&gt; (data += chunk)); 
        req.on("end", () =&gt; resolve(JSON.parse(data))); 
        req.on("error", reject); 
    }); 
}

const server = http.createServer(async function (req, res) { 
    switch (req.method) { 
        case "POST":
          return res.end("POST request received");
        default:
          return res.end("non-POST request received");
    }
})

const port = Number(process.env.PORT) || 8000; 
server.listen(port, function () { 
    console.log("server running on port", port); 
});
</code></pre>
<p>In the code snippet above, the <code>http</code> module is imported from Node.js. The <code>parseRequestBody</code> function converts the request body stream of a HTTP request to a JavaScript object.</p>
<p>It responds with <code>POST request received</code> for POST requests and <code>non-POST request received</code> for all others. By default, it listens on port 8000 unless a <code>PORT</code> environment variable is defined.</p>
<p>Run <code>npm run start</code> to start the server. To confirm it is running, execute the following command in the terminal:</p>
<pre><code class="language-shell"># For Linux/Mac, use:
curl -X POST -H "Content-Type: application/json" -d '{"prompt":"hello"}' http://localhost:8000

# For Windows, use:
curl.exe -X POST -H "Content-Type: application/json" -d '{"prompt":"hello"}' http://localhost:8000
</code></pre>
<p>You'll get the <code>POST request received</code> response from the server.</p>
<h3 id="heading-integrate-an-ai-assistant-into-the-nodejs-application">Integrate an AI Assistant into the Node.js Application</h3>
<p>In this section, you'll integrate the AI assistant into the backend application, prompt it with data sent from the frontend, and return its response to the client. Again, we'll use Gemini for this here.</p>
<p>Visit the npm page for your chosen AI assistant to learn how to install and set it up. Here are the npm pages for the most popular AI assistants:</p>
<ul>
<li><p><a href="https://www.npmjs.com/package/@anthropic-ai/sdk">Anthropic AI</a></p>
</li>
<li><p><a href="https://www.npmjs.com/package/@google/genai">Google Gemini</a></p>
</li>
<li><p><a href="https://www.npmjs.com/package/openai">Open AI</a></p>
</li>
</ul>
<p>Update the <code>index.js</code> file to include the setup for the AI assistant using the snippet below:</p>
<pre><code class="language-javascript">import http from "node:http";
import { GoogleGenAI } from "@google/genai"; 

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });

async function parseRequestBody(req) { /* minimised code */ }

const server = http.createServer(async function (req, res) {
    res.setHeader("Access-Control-Allow-Origin", "*");

    switch (req.method) { 
        case "POST":
          const body = await parseRequestBody(req);
          const response = await ai.models.generateContent({
            model: "gemini-2.5-flash", // or whatever model you have
            contents: body.prompt,
         });

         return res.end(response.text);

        default:
          return res.end("non-POST request received");
    }
}
/* previous code minimised*/
</code></pre>
<p>The <code>GEMINI_API_KEY</code> is retrieved from the environment variables and passed as the <code>apiKey</code> to <code>GoogleGenAI</code>, which initialises the AI assistant.</p>
<p>The POST request body is parsed into a JavaScript object, and <code>body.prompt</code> is passed to <code>ai.models.generateContent</code> to prompt the AI assistant. The <code>text</code> property of the response which is in Markdown format, is then returned to the client.</p>
<p>Restart the server and test the current setup by making an API request to it with curl using the snippet below:</p>
<pre><code class="language-shell"># For Linux/Mac:

curl -X POST -H "Content-Type: application/json" -d '{"prompt":"hello"}' http://localhost:8000

# For Windows:

curl.exe -X POST -H "Content-Type: application/json" -d '{"prompt":"hello"}' http://localhost:8000
</code></pre>
<p>You'll get an AI text response in the form of Markdown.</p>
<h3 id="heading-create-the-frontend-application-with-vite">Create the Frontend Application with Vite</h3>
<p><a href="https://vite.dev/">Vite</a> is a build tool that provides a faster and more seamless development experience for developing applications. You'll use Vite to create the frontend application and connect it with the backend application from the previous section.</p>
<p>In another folder, create a project with Vite by running the <code>npm create vite@latest</code> command and answer the prompts:</p>
<pre><code class="language-shell">npm create vite@latest

Need to install the following packages:
create-vite@8.1.0
Ok to proceed? (y) y

&gt; npx create-vite

◇  Project name:
│  [name-of-your-frontend-app] e.g prompt-ai-with-speech-frontend
│
◇  Select a framework:
│  Vanilla
│
◇  Select a variant:
│  JavaScript
│
◇  Use rolldown-vite (Experimental)?:
│  No
│
◇  Install with npm and start now?
│  Yes
</code></pre>
<p>Open the project created in your code editor and make the following updates:</p>
<p>First, replace the content of <code>index.html</code> with the code snippet below:</p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
  &lt;head&gt;
    &lt;meta charset="UTF-8" /&gt;
    &lt;meta name="viewport" content="width=device-width, initial-scale=1.0" /&gt;
    &lt;title&gt;Prompt AI with the Web Speech Recognition API&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;main id="app"&gt;
      &lt;section&gt;
        &lt;h1&gt;Prompt AI with the Web Speech Recognition API&lt;/h1&gt;
        &lt;ul id="ulist_chat"&gt;&lt;/ul&gt;
      &lt;/section&gt;
      &lt;div class="btn_container"&gt;
        &lt;button id="btn_record"&gt;Record prompt&lt;/button&gt;
      &lt;/div&gt;
    &lt;/main&gt;
    &lt;script type="module" src="/src/main.js"&gt;&lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>Then replace the content of <code>src/style.css</code> with the code snippet below:</p>
<pre><code class="language-css">:root {
  font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  font-weight: 400;

  color-scheme: light dark;
  color: rgba(255, 255, 255, 0.87);
  background-color: #242424;

  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

button {
  border-radius: 8px;
  border: 1px solid transparent;
  padding: 0.6em 1.2em;
  font-size: 1em;
  font-weight: 500;
  font-family: inherit;
  background-color: #1a1a1a;
  cursor: pointer;
  transition: border-color 0.25s;
}
button:hover {
  border-color: #646cff;
}
button:focus,
button:focus-visible {
  outline: 4px auto -webkit-focus-ring-color;
}
.btn_container {
  padding: 16px 0px;
  display: flex;
  justify-content: center;
}

#ulist_chat {
  display: flex;
  flex-direction: column;
  width: 80%;
  margin: auto;
  padding: 0;
}

#ulist_chat .transcript {
  border: 1px solid tomato;
  background: #fce5e5af;
  border-radius: 4px;
  align-self: flex-end;
  list-style-type: none;
  margin: 8px;
  padding: 8px;
  max-width: 80%;
}

#ulist_chat .ai_response p { 
  margin: 2px; 
}

#ulist_chat .ai_response {
  border: 1px solid green;
  background: #e5fce8af;
  border-radius: 4px;
  align-self: flex-start;
  list-style-type: none;
  margin: 8px;
  padding: 8px;
  max-width: 80%;
}

@media (prefers-color-scheme: light) {
  :root {
    color: #000;
    background-color: #ffffff;
  }
  a:hover {
    color: #747bff;
  }
  button {
    background-color: #f9f9f9;
  }
}
</code></pre>
<p>Now replace the content of <code>src/main.js</code> with the code snippet below:</p>
<pre><code class="language-javascript">import "./style.css";
import { marked } from "marked";

const apiUrl = "http://localhost:8000";
const btnRecord = document.getElementById("btn_record");
const uListChat = document.getElementById("ulist_chat");

function ensureBrowserHasSpeechAPI() {
  if (
    !("webkitSpeechRecognition" in window) &amp;&amp;
    !("SpeechRecognition" in window)
  ) {
    btnRecord.style.display = "none";

    return alert(
      "This browser does not have the features required for this demo. Use Google Chrome &gt;= v33"
    );
  }

  start();
}

function toggleRecording(config, listener) {
  if (config.isListening) {
    config.isListening = false;
    btnRecord.innerText = "Start recording";
    return listener.stop();
  }

  config.isListening = true;
  btnRecord.innerText = "Stop recording";

  return listener.start();
}

/** @param {string} transcript  */
function appendTranscriptToChatList(transcript) {
  const li = document.createElement("li");
  li.innerText = transcript;
  li.classList.add("transcript");
  uListChat.appendChild(li);
}

/** @param {string} aiResponse  */
function appendAIResponseToChatList(aiResponse) {
  const li = document.createElement("li");
  li.innerHTML = marked.parse(aiResponse);
  li.classList.add("ai_response");
  uListChat.appendChild(li);
}

/** @param {string} prompt  */
async function promptAI(prompt) {
  try {
    const response = await fetch(apiUrl, {
      body: JSON.stringify({ prompt }),
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
    });

    if (!response.ok) {
      const err = await response.text();
      console.error(err);
      alert("An error occurred. Try again");
      return;
    }

    const text = await response.text();
    return text;
  } catch (error) {
    logError(error);
    alert("An error occurred. Try again");
    return ""
  }
}

function setUpSpeechRecognition() {
  const SpeechRecognition =
    window.SpeechRecognition || window.webkitSpeechRecognition;

  const listener = new SpeechRecognition();
  listener.continuous = true; // listen for long speech
  listener.maxAlternatives = 2; // only two transcription suggestions required
  let transcript = "";

  // automatic: onstart -&gt; onaudiostart -&gt; onsoundstart -&gt; onspeechstart
  // automatic: onspeechend -&gt; onsoundend -&gt; onaudioend -&gt; onresult -&gt; onend
  // click button: onaudioend -&gt; onresult -&gt; onend

  listener.onend = async function () {
    if (!transcript || !transcript.trim()) return;

    btnRecord.innerText = "Thinking...";
    btnRecord.disabled = true;
    appendTranscriptToChatList(transcript);
    promptAI(transcript)
      .then(function (res) {
        appendAIResponseToChatList(res);
      })
      .finally(function () {
        btnRecord.innerText = "Record prompt";
        btnRecord.disabled = false;
        transcript = "";
      });
  };

  listener.onerror = function (err) {
    logError(err);
    alert("Error occurred while capturing speech");
  };

  listener.onresult = function (event) {
    for (const alternatives of event.results) {
      const [bestAlternative] = Array.from(alternatives).toSorted(
        (altA, altB) =&gt; altB.confidence - altA.confidence
      );

      transcript += bestAlternative.transcript;
    }
  };

  return listener;
}

async function start() {
  const config = {
    isListening: false,
  };

  const listener = setUpSpeechRecognition();

  btnRecord.addEventListener("click", function () {
    toggleRecording(config, listener);
  });
}

ensureBrowserHasSpeechAPI();

function logError(...str) {
  for (const s of str) {
    console.error("error:", s);
  }
}
</code></pre>
<p><a href="https://www.npmjs.com/package/marked"><code>marked</code></a> is an npm package that helps convert Markdown text to HTML and it's a required dependency in the project. Install <code>marked</code> in the project by running the following command in the project's terminal:</p>
<pre><code class="language-shell">npm install marked
</code></pre>
<p>The <code>ensureBrowserHasSpeechAPI</code> function in <code>src/main.js</code> checks to see if the browser in use has the <code>WebSpeechAPI</code> feature. If it doesn't, it prevents the application from displaying the controls for the UI. That's why you'll need a Google Chrome browser with a version greater than or equal to 33 for this guide. Those versions have the <code>WebSpeechAPI</code> feature.</p>
<p>The <code>toggleRecording</code> function executes when the <strong>Record prompt</strong> button is clicked. On the first click, it requests microphone permission. It also enables/disables the activity of the <code>SpeechRecognition</code> instance.</p>
<p>The <code>setUpSpeechRecognition</code> function sets up the <code>SpeechRecognition</code> instance: <code>listener</code>, and its configuration. It also attaches functions to be run when the <code>end</code>, <code>error</code> and <code>result</code> events are triggered.</p>
<ul>
<li><p><code>error</code> is triggered when there is an error in capturing or processing audio</p>
</li>
<li><p><code>result</code> is triggered when the recognition engine returns transcription results</p>
</li>
<li><p><code>end</code> is triggered when the speech recognition service has disconnected from the application.</p>
</li>
</ul>
<p>The transcript is displayed on the UI after passing it as an argument to the <code>appendTranscriptToChatList</code> function.</p>
<p>The <code>promptAI</code> function executes when the <code>end</code> event fires, accepting the speech transcript as an argument and sending it to the backend via a POST request using <code>fetch</code>. On success, the AI response is returned as Markdown and passed to <code>appendAIResponseToChatList</code>, which converts it to HTML and displays it on the UI.</p>
<h2 id="heading-test-the-application-locally">Test the Application Locally</h2>
<p>Start the backend application by running <code>npm run start</code> in the backend project's terminal and start the frontend application by running <code>npm run dev</code> in the frontend project's terminal. Visit <code>http://localhost:5173</code> to view the UI of application. You should see a UI similar to the one in the image below:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e28b713f978a0e2cd2b763/c5d6fc8b-d693-4b83-9611-8e0c493c9f7c.png" alt="Initial image of the voice-powered AI chat app UI built with the Web Speech API" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Click the <strong>Record prompt</strong> button. A prompt will appear requesting microphone permission. Select "Allow while visiting the site" or "Allow this time" to grant access and begin recording. Click on the <strong>Stop recording</strong> button when you're done.</p>
<p>The UI will display the transcript of your speech and the application will send it to the backend as a prompt. After waiting for a short while, you'll see the response from the AI assistant displayed on the UI.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e28b713f978a0e2cd2b763/7a4ff532-6bd0-478c-ae3d-f6446c9d0a1f.png" alt="Final image of the voice-powered AI chat app UI built with the Web Speech API" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>You have been able to use speech input to prompt an AI assistant, receive a response and display it. How do you make this application accessible to everyone? The next section guides you through deploying both applications.</p>
<h2 id="heading-deploy-the-backend-application-with-google-cloud-run">Deploy the Backend Application with Google Cloud Run</h2>
<p>In this section, you'll deploy the backend application with Google Cloud Run and get a URL which will be used as the <code>apiUrl</code> in the frontend application.</p>
<p>In order to host the backend application with Google Cloud Run, you need to have a:</p>
<ul>
<li><p>Google Cloud developer account</p>
</li>
<li><p>Google Cloud project</p>
</li>
</ul>
<p>Visit <a href="https://cloud.google.com/">Google Cloud</a> to create an account and create a project. You can name the project whatever you want but it's a good idea to give a descriptive name. Take note of the project's ID because you'll use it in the deployment process.</p>
<p>There are three ways to deploy applications on Google Cloud Run:</p>
<ul>
<li><p>Deploy a revision from an existing container image</p>
</li>
<li><p>Deploy from a repository such as GitHub or GitLab</p>
</li>
<li><p>Create a function using the inline editor</p>
</li>
</ul>
<p>You can see all three options if you visit the <a href="https://console.cloud.google.com/run/create">create Cloud Run service</a> page.</p>
<p>In this guide, you'll use the option to deploy from an existing container image. Follow the steps below to deploy the backend server from a container image or follow the Cloud Run documentation at <a href="https://docs.cloud.google.com/run/docs/quickstarts/build-and-deploy/deploy-nodejs-service">build and deploy Node.js service on Cloud Run</a>:</p>
<ul>
<li><p>Install the Google Cloud (gcloud) CLI on your computer by visiting the <a href="https://docs.cloud.google.com/sdk/docs/install">Install Google Cloud CLI</a> page and following the instructions on the page for your operating system</p>
</li>
<li><p>Initialise the gcloud CLI to connect it to your developer account by visiting the <a href="https://docs.cloud.google.com/sdk/docs/initializing">Initializing the gcloud CLI</a> page and following the instructions on the page</p>
</li>
<li><p>Set the project you want to deploy the backend server under by running the command below in your terminal:</p>
</li>
</ul>
<pre><code class="language-shell"># replace PROJECT_ID with your project ID

gcloud config set project PROJECT_ID
</code></pre>
<ul>
<li><p>Visit your project's <a href="https://console.cloud.google.com/iam-admin/iam">IAM Admin</a> page to enable the following roles on the service account created for this project:</p>
<ul>
<li><p><code>roles/run.sourceDeveloper</code></p>
</li>
<li><p><code>roles/iam.serviceAccountUser</code></p>
</li>
<li><p><code>roles/logging.viewer</code></p>
</li>
</ul>
</li>
</ul>
<p>These roles are required to enable the Cloud Run Admin API and Cloud Build APIs. Take note of the service account email address.</p>
<ul>
<li>Enable the Cloud Run Admin API and Cloud Build APIs by running the code snippet below in your terminal:</li>
</ul>
<pre><code class="language-shell">gcloud services enable run.googleapis.com cloudbuild.googleapis.com
</code></pre>
<ul>
<li>Grant the Cloud Build service account access to your project by running the code snippet below in your terminal:</li>
</ul>
<pre><code class="language-plaintext"># replace PROJECT_ID with your project ID and 
# SERVICE_ACCOUNT_EMAIL_ADDRESS with the service account's email address

gcloud projects add-iam-policy-binding PROJECT_ID \
--member=serviceAccount:SERVICE_ACCOUNT_EMAIL_ADDRESS \
--role=roles/run.builder
</code></pre>
<p>Update <code>index.js</code> in the backend project to restrict API requests to clients specified in the <code>ALLOWED_ORIGINS</code> environment variable, and update the AI assistant configuration to use the API key loaded from environment variables.</p>
<pre><code class="language-javascript">// Use the API key from the environment variable
const GEMINI_API_KEY = process.env.GEMINI_API_KEY; 
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });

// Replace res.setHeader("Access-Control-Allow-Origin", "*"); with
res.setHeader("Access-Control-Allow-Origin", process.env.ALLOWED_ORIGINS);
res.setHeader("Access-Control-Allow-Methods", "POST,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
</code></pre>
<p>This ensures that the application will receive POST requests from only frontend URLs specified in the <code>ALLOWED_ORIGINS</code> environment variable. This setup prevents the backend from being loaded with requests from frontend clients that you don't know, and also prevents the excess use of your tokens. It also keeps you from deploying the application with the AI API key hardcoded in it.</p>
<p>To test that the new changes work, run the backend application with the command below:</p>
<pre><code class="language-shell"># replace YOUR_API_KEY with your Gemini API key

GEMINI_API_KEY=YOUR_API_KEY ALLOWED_ORIGINS="http://localhost:5173" npm run start
</code></pre>
<p>With the command in the code snippet above, the backend application will not respond to requests from frontend applications not hosted on <code>http://localhost:5173</code>. Try to send a prompt from the frontend application to test that it works.</p>
<p>To deploy the backend application to Cloud Run, run the command in the snippet below in the terminal of the backend project folder. The command sets the environment variables required for the application to run and also deploys it to Google Cloud Run.</p>
<pre><code class="language-plaintext"># replace &lt;api-key&gt; with your Gemini API key

gcloud run deploy --source . \
--set-env-vars "ALLOWED_ORIGINS=http://localhost:5173" \
--set-env-vars "GEMINI_API_KEY=&lt;api-key&gt;"
</code></pre>
<p>Once deployment is complete, you'll receive the URL of your hosted backend. Copy it and replace the value of <code>apiUrl</code> in your frontend application with it. Run the frontend, record a prompt, and confirm that everything works as expected.</p>
<h2 id="heading-deploy-the-frontend-application-with-firebase">Deploy the Frontend Application with Firebase</h2>
<p>In this section, you'll host the frontend application with Firebase. You need to have a Firebase account. Follow the steps below to host the frontend with Firebase:</p>
<ul>
<li><p>Create and set up a Firebase project</p>
</li>
<li><p>Install the Firebase CLI by visiting the <a href="https://firebase.google.com/docs/cli#install_the_firebase_cli">install Firebase CLI</a> page and follow the instructions for your operating system</p>
</li>
<li><p>In the terminal of the frontend project, run <code>firebase init hosting</code> to initialise the hosting configuration for the project. Follow the prompts and use <code>dist</code> as the public directory when prompted</p>
</li>
<li><p>Run <code>firebase deploy --only hosting</code> to host the application with Firebase</p>
</li>
</ul>
<p>Once deployment is complete, you will receive the URL of your hosted frontend application.</p>
<h2 id="heading-connect-the-deployed-applications">Connect the Deployed Applications</h2>
<p>Remember that the first time you deployed your backend application, you set <code>ALLOWED_ORIGINS</code> to <code>http://localhost:5173</code>. The deployed backend application doesn't know about the URL of the deployed frontend application so it won't accept requests from it.</p>
<p>In the terminal of the backend application, deploy the backend application again using the command in the snippet below:</p>
<pre><code class="language-shell"># replace &lt;frontend-url&gt; with your Firebase frontend URL and &lt;api-key&gt; 
# with your Gemini API key

gcloud run deploy --source . \
--set-env-vars "ALLOWED_ORIGINS=&lt;frontend-url&gt;" --set-env-vars "GEMINI_API_KEY=&lt;api-key&gt;"
</code></pre>
<p>Visit the deployed frontend application and test it. It should work without errors.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this guide, you built a frontend application that captures and transcribes speech, a Node.js backend application that prompts AI, and you connected both applications together to build a simplified version of the <strong>Use Voice</strong> feature in AI chat applications.</p>
<p>Can you add a feature to the application that will make it read out the response from the backend when it receives it? You can use the <a href="https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesis"><code>SpeechSynthesis</code> API</a> to build it.</p>
<p>Feel free to <a href="https://www.linkedin.com/in/orimdominicadah/">connect with me on LinkedIn</a> if you have any questions. Thank you for reading this far and don’t hesitate to share this article if you found it insightful. Cheers!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Ship a Production-Ready RAG App with FAISS (Guardrails, Evals, and Fallbacks) ]]>
                </title>
                <description>
                    <![CDATA[ Most LLM applications look great in a high-fidelity demo. Then they hit the hands of real users and start failing in very predictable yet damaging ways. They answer questions they should not, they bre ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-rag-app-faiss-fastapi/</link>
                <guid isPermaLink="false">69b841572ad6ae5184d54317</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ FastAPI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ RAG  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ vector database ]]>
                    </category>
                
                    <category>
                        <![CDATA[ faiss ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chidozie Managwu ]]>
                </dc:creator>
                <pubDate>Mon, 16 Mar 2026 17:43:51 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/f9da3ad9-e285-4ce1-acb7-ad119579971c.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most LLM applications look great in a high-fidelity demo. Then they hit the hands of real users and start failing in very predictable yet damaging ways.</p>
<p>They answer questions they should not, they break when document retrieval is weak, they time out due to network latency, and nobody can tell exactly what happened because there are no logs and no tests.</p>
<p>In this tutorial, you’ll build a beginner-friendly Retrieval Augmented Generation (RAG) application designed to survive production realities. This isn’t just a script that calls an API. It’s a system featuring a FastAPI backend, a persisted FAISS vector store, and essential safety guardrails (including a retrieval gate and fallbacks).</p>
<h3 id="heading-table-of-contents">Table of Contents</h3>
<ol>
<li><p><a href="#heading-why-rag-alone-does-not-equal-productionready">Why RAG Alone Does Not Equal Production-Ready</a></p>
</li>
<li><p><a href="#heading-the-architecture-you-are-building">The Architecture You Are Building</a></p>
</li>
<li><p><a href="#heading-project-setup-and-structure">Project Setup and Structure</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-rag-layer-with-faiss">How to Build the RAG Layer with FAISS</a></p>
</li>
<li><p><a href="#heading-how-to-add-the-llm-call-with-structured-output">How to Add the LLM Call with Structured Output</a></p>
</li>
<li><p><a href="#heading-how-to-add-guardrails-retrieval-gate-and-fallbacks">How to Add Guardrails: Retrieval Gate and Fallbacks</a></p>
</li>
<li><p><a href="#heading-fast-api-app-creating-the-answer-endpoint">FastAPI App: Creating the /answer Endpoint</a></p>
</li>
<li><p><a href="#heading-how-to-add-beginnerfriendly-evals">How to Add Beginner-Friendly Evals</a></p>
</li>
<li><p><a href="#heading-what-to-improve-next-realistic-upgrades">What to Improve Next: Realistic Upgrades</a></p>
</li>
</ol>
<h2 id="heading-why-rag-alone-does-not-equal-production-ready">Why RAG Alone Does Not Equal Production-Ready</h2>
<p>Retrieval Augmented Generation (RAG) is often hailed as the hallucination killer. By grounding the model in retrieved text, we provide it with the facts it needs to be accurate. But simply connecting a vector database to an LLM isn’t enough for a production environment.</p>
<p>Production issues usually arise from the silent failures in the system surrounding the model:</p>
<ul>
<li><p><strong>Weak retrieval:</strong> If the app retrieves irrelevant chunks of text, the model tries to bridge the gap by inventing an answer anyway. Without a designated “I do not know” path, the model is essentially forced to hallucinate.</p>
</li>
<li><p><strong>Lack of visibility:</strong> Without structured outputs and basic logging, you can’t tell if bad retrieval, a confusing prompt, or a model update caused a wrong answer.</p>
</li>
<li><p><strong>Fragility:</strong> A simple API timeout or malformed provider response becomes a user-facing outage if you don’t implement fallbacks.</p>
</li>
<li><p><strong>No regression testing:</strong> In traditional software, we have unit tests. In AI, we need evals. Without them, a small tweak to your prompt might fix one issue but break ten others without you realising it.</p>
</li>
</ul>
<p>We’ll solve each of these issues systematically in this guide.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This tutorial is beginner-friendly, but it assumes you have a few basics in place so you can focus on building a robust RAG system instead of getting stuck on setup issues.</p>
<h3 id="heading-knowledge">Knowledge</h3>
<p>You should be comfortable with:</p>
<ul>
<li><p><strong>Python fundamentals</strong> (functions, modules, virtual environments)</p>
</li>
<li><p><strong>Basic HTTP + JSON</strong> (requests, response payloads)</p>
</li>
<li><p><strong>APIs with FastAPI</strong> (what an endpoint is and how to run a server)</p>
</li>
<li><p><strong>High-level LLM concepts</strong> (prompting, temperature, structured outputs)</p>
</li>
</ul>
<h3 id="heading-tools-accounts">Tools + Accounts</h3>
<p>You’ll need:</p>
<ul>
<li><p><strong>Python 3.10+</strong></p>
</li>
<li><p>A working <strong>OpenAI-compatible API key</strong> (OpenAI or any provider that supports the same request/response shape)</p>
</li>
<li><p>A local environment where you can run a FastAPI app (Mac/Linux/Windows)</p>
</li>
</ul>
<h3 id="heading-what-this-tutorial-covers-and-what-it-doesnt">What This Tutorial Covers (and What It Doesn’t)</h3>
<p>We’ll build a production-minded baseline:</p>
<ul>
<li><p>A <strong>FAISS-backed retriever</strong> with a persisted index + metadata</p>
</li>
<li><p>A <strong>retrieval gate</strong> to prevent “forced hallucination”</p>
</li>
<li><p><strong>Structured JSON outputs</strong> so your backend is stable</p>
</li>
<li><p><strong>Fallback behavior</strong> for timeouts and provider errors</p>
</li>
<li><p>A small <strong>eval harness</strong> to prevent regressions</p>
</li>
</ul>
<p>We won’t implement advanced upgrades such as rerankers, semantic chunking, auth, background jobs beyond a roadmap at the end.</p>
<h2 id="heading-the-architecture-you-are-building">The Architecture You Are Building</h2>
<p>The flow of our application follows a disciplined path so every answer is grounded in evidence:</p>
<ol>
<li><p><strong>User query:</strong> The user submits a question via a FastAPI endpoint.</p>
</li>
<li><p><strong>Retrieval:</strong> The system embeds the question and retrieves the top-k most similar document chunks.</p>
</li>
<li><p><strong>The retrieval gate:</strong> We evaluate the similarity score. If the context is not relevant enough, we stop immediately and refuse the query.</p>
</li>
<li><p><strong>Augmentation and generation:</strong> If the gate passes, we send a context-augmented prompt to the LLM.</p>
</li>
<li><p><strong>Structured response:</strong> The model returns a JSON object containing the answer, sources used, and a confidence level.</p>
</li>
</ol>
<h2 id="heading-project-setup-and-structure">Project Setup and Structure</h2>
<p>To keep things organized and maintainable, we’ll use a modular structure. This allows you to swap out your LLM provider or your vector database without rewriting your entire core application.</p>
<h3 id="heading-project-structure">Project Structure</h3>
<pre><code class="language-python">.
├── app.py              # FastAPI entry point and API logic
├── rag.py              # FAISS index, persistence, and document retrieval
├── llm.py              # LLM API interface and JSON parsing
├── prompts.py          # Centralized prompt templates
├── data/               # Source .txt documents
├── index/              # Persisted FAISS index and metadata
└── evals/              # Evaluation dataset and runner script
    ├── eval_set.json
    └── run_evals.py
</code></pre>
<h3 id="heading-install-dependencies">Install Dependencies</h3>
<p>First, create a virtual environment to isolate your project:</p>
<pre><code class="language-python">python -m venv .venv
source .venv/bin/activate  # On Windows: .venv\Scripts\activate
pip install fastapi uvicorn faiss-cpu numpy pydantic requests python-dotenv
</code></pre>
<h3 id="heading-configure-the-environment">Configure the Environment</h3>
<p>Create a <code>.env</code> file in the root directory. We are targeting OpenAI-compatible providers:</p>
<pre><code class="language-python">OPENAI_API_KEY=your_actual_api_key_here
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=gpt-4o-mini
</code></pre>
<p>Important note on compatibility: The code below assumes an OpenAI-style API. If you use a provider that is not compatible, you must change the URL, headers (for example <code>X-API-Key</code>), and the way you extract embeddings and final message content in <code>embed_texts()</code> and <code>call_llm()</code>.</p>
<h2 id="heading-how-to-build-the-rag-layer-with-faiss">How to Build the RAG Layer with FAISS</h2>
<p>In <code>rag.py</code>, we handle the “Retriever” part of RAG. This involves turning raw text into mathematical vectors that the computer can compare.</p>
<h3 id="heading-what-is-faiss-and-what-does-it-do">What is FAISS (and What Does It Do)?</h3>
<p><strong>FAISS</strong> (Facebook AI Similarity Search) is a fast library for vector similarity search. In a RAG system, each chunk of text becomes an embedding vector (a list of floats). FAISS stores those vectors in an index so you can quickly ask:</p>
<blockquote>
<p>“Given this question embedding, which document chunks are closest to it?”</p>
</blockquote>
<p>In this tutorial, we use <code>IndexFlatIP</code> inner product and normalise vectors with <code>faiss.normalize_L2(...)</code>. With normalised vectors, the inner product behaves like <strong>cosine similarity</strong>, giving us a stable score we can use for a retrieval gate.</p>
<h3 id="heading-chunking-strategy-with-overlap">Chunking Strategy With Overlap</h3>
<p>We’ll use chunking with overlap. If we split a document at exactly 1,000 characters, we might cut a sentence in half, losing its meaning. By using an overlap, for example, 200 characters, we ensure that the end of one chunk and the beginning of the next share context.</p>
<h3 id="heading-implementation-of-ragpy">Implementation of <code>rag.py</code></h3>
<pre><code class="language-python">import os
import faiss
import numpy as np
import requests
import json
from typing import List, Dict
from dotenv import load_dotenv

load_dotenv()

INDEX_PATH = "index/faiss.index"
META_PATH = "index/meta.json"

def chunk_text(text: str, size: int = 1000, overlap: int = 200) -&gt; List[str]:
    chunks = []
    step = max(1, size - overlap)
    for i in range(0, len(text), step):
        chunk = text[i : i + size].strip()
        if chunk:
            chunks.append(chunk)
    return chunks

def embed_texts(texts: List[str]) -&gt; np.ndarray:
    # Note: If your provider is not OpenAI-compatible, change this URL and headers
    url = f"{os.getenv('OPENAI_BASE_URL')}/embeddings"
    headers = {"Authorization": f"Bearer {os.getenv('OPENAI_API_KEY')}"}
    payload = {"input": texts, "model": "text-embedding-3-small"}

    resp = requests.post(url, headers=headers, json=payload, timeout=30)
    resp.raise_for_status()
    # If your provider uses a different response format, change the line below
    vectors = np.array([item["embedding"] for item in resp.json()["data"]], dtype="float32")
    return vectors

def build_index() -&gt; None:
    all_chunks: List[str] = []
    metadata: List[Dict] = []

    if not os.path.exists("data"):
        os.makedirs("data")
        return

    for file in os.listdir("data"):
        if not file.endswith(".txt"):
            continue

        with open(f"data/{file}", "r", encoding="utf-8") as f:
            text = f.read()

        chunks = chunk_text(text)
        all_chunks.extend(chunks)
        for c in chunks:
            metadata.append({"source": file, "text": c})

    if not all_chunks:
        return

    embeddings = embed_texts(all_chunks)
    faiss.normalize_L2(embeddings)

    dim = embeddings.shape[1]
    index = faiss.IndexFlatIP(dim)
    index.add(embeddings)

    os.makedirs("index", exist_ok=True)
    faiss.write_index(index, INDEX_PATH)

    with open(META_PATH, "w", encoding="utf-8") as f:
        json.dump(metadata, f, ensure_ascii=False)

def load_index():
    if not (os.path.exists(INDEX_PATH) and os.path.exists(META_PATH)):
        raise FileNotFoundError(
            "FAISS index not found. Add .txt files to data/ and run build_index()."
        )

    index = faiss.read_index(INDEX_PATH)
    with open(META_PATH, "r", encoding="utf-8") as f:
        metadata = json.load(f)
    return index, metadata

def retrieve(query: str, k: int = 5) -&gt; List[Dict]:
    index, metadata = load_index()

    q_emb = embed_texts([query])
    faiss.normalize_L2(q_emb)

    scores, ids = index.search(q_emb, k)
    results = []
    for score, idx in zip(scores[0], ids[0]):
        if idx == -1:
            continue
        m = metadata[idx]
        results.append(
            {"score": float(score), "source": m["source"], "text": m["text"], "id": int(idx)}
        )
    return results
</code></pre>
<h2 id="heading-how-to-add-the-llm-call-with-structured-output">How to Add the LLM Call with Structured Output</h2>
<p>A major failure point in AI apps is the “chatty” nature of LLMs. If your backend expects a list of sources but the LLM returns conversational filler, your code will crash.</p>
<p>We solve this with <strong>structured output</strong>: instruct the model to return a strict JSON object, then parse it safely.</p>
<h3 id="heading-implementation-of-llmpy">Implementation of <code>llm.py</code></h3>
<pre><code class="language-python">import json
import requests
import os
from typing import Dict, Any

def call_llm(system_prompt: str, user_prompt: str) -&gt; Dict[str, Any]:
    # Note: Change URL/Headers if using a non-OpenAI compatible provider
    url = f"{os.getenv('OPENAI_BASE_URL')}/chat/completions"
    headers = {
        "Authorization": f"Bearer {os.getenv('OPENAI_API_KEY')}",
        "Content-Type": "application/json",
    }

    payload = {
        "model": os.getenv("OPENAI_MODEL"),
        "messages": [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt},
        ],
        "response_format": {"type": "json_object"},
        "temperature": 0,
    }

    try:
        resp = requests.post(url, headers=headers, json=payload, timeout=30)
        resp.raise_for_status()
        content = resp.json()["choices"][0]["message"]["content"]

        parsed = json.loads(content)
        parsed.setdefault("answer", "")
        parsed.setdefault("refusal", False)
        parsed.setdefault("confidence", "medium")
        parsed.setdefault("sources", [])
        return parsed

    except (requests.Timeout, requests.ConnectionError):
        return {
            "answer": "The system is temporarily unavailable (network issue). Please try again.",
            "refusal": True,
            "confidence": "low",
            "sources": [],
            "error_type": "network_error",
        }
    except Exception:
        return {
            "answer": "A system error occurred while generating the answer.",
            "refusal": True,
            "confidence": "low",
            "sources": [],
            "error_type": "unknown_error",
        }
</code></pre>
<h2 id="heading-how-to-add-guardrails-retrieval-gate-and-fallbacks">How to Add Guardrails: Retrieval Gate and Fallbacks</h2>
<p>Guardrails are interceptors. They sit between the user and the model to prevent predictable failures.</p>
<h3 id="heading-the-retrieval-gate-how-it-works-and-how-to-add-it">The Retrieval Gate: How It Works and How to Add It</h3>
<p>In a standard RAG pipeline, the system always calls the LLM. If the user asks an irrelevant question, the retriever will still return the “closest” (but wrong) chunks.</p>
<p>The solution is the retrieval gate:</p>
<ol>
<li><p>Retrieve top-k chunks and get the <strong>top similarity score</strong></p>
</li>
<li><p>If the score is below a threshold (for example <code>0.30</code>), refuse immediately</p>
</li>
<li><p>Only call the LLM when retrieval is strong enough to ground the answer</p>
</li>
</ol>
<p>A threshold of <code>0.30</code> is a reasonable starting point when using normalised cosine similarity, but you should tune it using evals (next section).</p>
<h3 id="heading-fallbacks-and-why-they-matter">Fallbacks and Why They Matter</h3>
<p>Fallbacks ensure that if an API fails or times out, the user gets a helpful message instead of a crash. They also keep your API response shape consistent, which prevents frontend errors and makes logging meaningful.</p>
<p>In this tutorial, fallbacks are implemented inside <code>call_llm()</code> so your FastAPI layer stays simple.</p>
<h2 id="heading-fastapi-app-creating-the-answer-endpoint">FastAPI App: Creating the /answer Endpoint</h2>
<p>The <code>app.py</code> file is the conductor. It ties retrieval, guardrails, prompting, and generation together.</p>
<h3 id="heading-implementation-of-apppy">Implementation of <code>app.py</code></h3>
<pre><code class="language-python">from fastapi import FastAPI
from pydantic import BaseModel
from rag import retrieve
from llm import call_llm
import prompts
import time
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("rag_app")

app = FastAPI(title="Production-Ready RAG")

class QueryRequest(BaseModel):
    question: str

@app.post("/answer")
async def get_answer(req: QueryRequest):
    start_time = time.time()
    question = (req.question or "").strip()

    if not question:
        return {
            "answer": "Please provide a non-empty question.",
            "refusal": True,
            "confidence": "low",
            "sources": [],
            "latency_sec": round(time.time() - start_time, 2),
        }

    # 1) Retrieval
    results = retrieve(question, k=5)
    top_score = results[0]["score"] if results else 0.0

    logger.info("query=%r top_score=%.3f num_results=%d", question, top_score, len(results))

    # 2) Retrieval Gate (Guardrail)
    if top_score &lt; 0.30:
        return {
            "answer": "I do not have documents to answer that question.",
            "refusal": True,
            "confidence": "low",
            "sources": [],
            "latency_sec": round(time.time() - start_time, 2),
            "retrieval": {"top_score": top_score, "k": 5},
        }

    # 3) Augment
    context_text = "\n\n".join([f"Source {r['source']}: {r['text']}" for r in results])
    user_prompt = f"Context:\n{context_text}\n\nQuestion: {question}"

    # 4) Generation with Fallback
    response = call_llm(prompts.SYSTEM_PROMPT, user_prompt)

    # 5) Attach debug metadata
    response["latency_sec"] = round(time.time() - start_time, 2)
    response["retrieval"] = {"top_score": top_score, "k": 5}
    return response
</code></pre>
<h2 id="heading-centralized-prompt-template-promptspy">Centralized Prompt – Template: prompts.py</h2>
<p>A small but important habit: keep prompts centralised so they’re versionable and easy to evaluate.</p>
<h3 id="heading-example-promptspy">Example <code>prompts.py</code></h3>
<pre><code class="language-python">SYSTEM_PROMPT = """You are a RAG assistant. Use ONLY the provided Context to answer.
If the context does not contain the answer, respond with refusal=true.

Return a valid JSON object with exactly these keys:
- answer: string
- refusal: boolean
- confidence: "low" | "medium" | "high"
- sources: array of strings (source filenames you used)

Do not include any extra keys. Do not include markdown. Do not include commentary."""
</code></pre>
<h2 id="heading-how-to-add-beginner-friendly-evals">How to Add Beginner-Friendly Evals</h2>
<p>In AI systems, outputs are probabilistic. This makes testing harder than traditional software. Evals (evaluations) are a set of “golden questions” and “expected behaviours” you run repeatedly to detect regressions.</p>
<p>Instead of “does it output exactly this string,” you test:</p>
<ul>
<li><p>Should the app <strong>refuse</strong> when the retrieval is weak?</p>
</li>
<li><p>When it answers, does it include <strong>sources</strong>?</p>
</li>
<li><p>Is the behaviour stable across prompt tweaks and model changes?</p>
</li>
</ul>
<h3 id="heading-step-1-create-evalsevalsetjson">Step 1: Create <code>evals/eval_set.json</code></h3>
<p>This should contain both positive and negative cases.</p>
<pre><code class="language-json">[
  {
    "id": "in_scope_01",
    "question": "What is a retrieval gate and why is it important?",
    "expect_refusal": false,
    "notes": "Should explain gating and relate it to hallucination prevention."
  },
  {
    "id": "out_of_scope_01",
    "question": "What is the capital of France?",
    "expect_refusal": true,
    "notes": "If the knowledge base only includes our docs, the app should refuse."
  },
  {
    "id": "edge_01",
    "question": "",
    "expect_refusal": true,
    "notes": "Empty input should not call the LLM."
  }
]
</code></pre>
<h3 id="heading-step-2-create-evalsrunevalspy">Step 2: Create <code>evals/run_evals.py</code></h3>
<p>This runner calls your API endpoint (end-to-end) and checks expected behaviours.</p>
<pre><code class="language-python">import json
import requests

API_URL = "http://127.0.0.1:8000/answer"

def run():
    with open("evals/eval_set.json", "r", encoding="utf-8") as f:
        cases = json.load(f)

    passed = 0
    failed = 0

    for case in cases:
        resp = requests.post(API_URL, json={"question": case["question"]}, timeout=60)
        resp.raise_for_status()
        out = resp.json()

        got_refusal = bool(out.get("refusal", False))
        expect_refusal = bool(case["expect_refusal"])

        ok = (got_refusal == expect_refusal)

        # Beginner-friendly: if it answers, sources should exist and be a list
        if not got_refusal:
            ok = ok and isinstance(out.get("sources"), list)

        if ok:
            passed += 1
            print(f"PASS {case['id']}")
        else:
            failed += 1
            print(f"FAIL {case['id']} expected_refusal={expect_refusal} got_refusal={got_refusal}")
            print("Output:", json.dumps(out, indent=2))

    print(f"\nDone. Passed={passed} Failed={failed}")
    if failed:
        raise SystemExit(1)

if __name__ == "__main__":
    run()
</code></pre>
<h3 id="heading-how-to-use-evals-in-practice">How to Use Evals in Practice</h3>
<p>Run your server:</p>
<pre><code class="language-python">uvicorn app:app --reload
</code></pre>
<p>In another terminal, run evals:</p>
<pre><code class="language-python">python evals/run_evals.py
</code></pre>
<p>If an eval fails, you have a concrete signal that something changed in retrieval, gating, prompting, or provider behaviour.</p>
<h2 id="heading-what-to-improve-next-realistic-upgrades">What to Improve Next: Realistic Upgrades</h2>
<p>Building a reliable RAG app is iterative. Here are realistic next steps:</p>
<ul>
<li><p><strong>Semantic chunking:</strong> Break text based on meaning instead of character count.</p>
</li>
<li><p><strong>Reranking:</strong> Use a cross-encoder reranker to reorder the top-k chunks for higher precision.</p>
</li>
<li><p><strong>Metadata filtering:</strong> Filter results by category, date, or department to reduce false positives.</p>
</li>
<li><p><strong>Better citations:</strong> Store chunk IDs and show exactly which chunk(s) the answer came from.</p>
</li>
<li><p><strong>Observability:</strong> Add request IDs, structured logs, and traces so “what happened?” is answerable.</p>
</li>
<li><p><strong>Async + background indexing:</strong> Move index building to a background job and keep the API responsive.</p>
</li>
</ul>
<h2 id="heading-final-thoughts-production-ready-is-a-set-of-habits">Final Thoughts: Production-Ready Is a Set of Habits</h2>
<p>Building an AI application that survives in the real world is about building a system that is predictable, measurable, and safe.</p>
<ul>
<li><p><strong>Retrieval quality is measurable:</strong> Use similarity scores to gate your LLM.</p>
</li>
<li><p><strong>Refusal is a feature:</strong> It is better to say “I do not know” than to lie.</p>
</li>
<li><p><strong>Fallbacks are mandatory:</strong> Design for the moment the API goes down.</p>
</li>
<li><p><strong>Evals prevent regressions:</strong> Never deploy a change without running your tests.</p>
</li>
</ul>
<h2 id="heading-about-me">About Me</h2>
<p>I am Chidozie Managwu, an award-winning AI Product Architect and founder focused on helping global tech talent build real, production-ready skills. I contribute to global AI initiatives as a GAFAI Delegate and lead AI Titans Network, a community for developers learning how to ship AI products.</p>
<p>My work has been recognized with the Global Tech Hero award and featured on platforms like HackerNoon.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Rate Limiter with Redis and Python to Scale Your Apps ]]>
                </title>
                <description>
                    <![CDATA[ If you've ever built a web application, you know that without a proper mechanism to control traffic, your application can become overwhelmed, leading to slow response times, server crashes, and a poor user experience. Even worse, it can leave you vul... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-a-rate-limiter-with-redis-and-python/</link>
                <guid isPermaLink="false">68dfe6e0dcc5f825f4d48c85</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cybersecurity ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Sravan Karuturi ]]>
                </dc:creator>
                <pubDate>Fri, 03 Oct 2025 15:08:16 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1759503803144/4d974610-95dc-4db8-989a-0d705dc4d431.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've ever built a web application, you know that without a proper mechanism to control traffic, your application can become overwhelmed, leading to slow response times, server crashes, and a poor user experience. Even worse, it can leave you vulnerable to Denial-of-Service (DoS) attacks. This is where rate limiting comes in.</p>
<p>In this tutorial, you’ll build a distributed rate limiter. This is the kind of system you need when your application is deployed across multiple servers or virtual machines, and you need to enforce a global limit on all incoming requests.</p>
<p>You’ll build a simple URL shortener application and then implement a robust rate limiter for it using a powerful and efficient combination of tools:</p>
<ul>
<li><p>Python and Flask for your web application.</p>
</li>
<li><p>Redis as your high-speed, centralized data store for tracking requests.</p>
</li>
<li><p>Terraform and Proxmox to define and provision your virtual machine infrastructure.</p>
</li>
<li><p>Docker to containerize your application for easy deployment.</p>
</li>
<li><p>Nginx as a load balancer to distribute traffic across your app servers.</p>
</li>
<li><p>k6 to load-test your system and prove that your rate limiter actually works.</p>
</li>
</ul>
<p>This is intended for new developers learning about various system design concepts or for experts who just want a refresher.</p>
<p>By the end of this guide, you'll understand not just the code, but the complete system architecture required to deploy a scalable, resilient application.</p>
<p>Let's get started!</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>While not absolutely required to follow along, I’d recommend setting up a Proxmox server on an old laptop to implement the topics you learn and code along with the article. I recommend this <a target="_blank" href="https://www.youtube.com/watch?v=5j0Zb6x_hOk&amp;list=PLT98CRl2KxKHnlbYhtABg6cF50bYa8Ulo">YouTube playlist</a> for getting started. Please note that I am in no way affiliated with this channel. I just found it helpful for me.</p>
<p>However, If you do not have a local Proxmox server, you can skip that part and just follow along to understand how a rate limiter is built and how it is set up to properly work with multiple servers.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-the-big-picture-our-system-architecture">The Big Picture: Our System Architecture</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-1-how-to-define-the-infrastructure-with-terraform">Step 1: How to Define the Infrastructure with Terraform</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-2-how-to-implement-the-rate-limiter-logic-in-python">Step 2: How to Implement the Rate Limiter Logic in Python</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-3-containerizing-and-testing">Step 3: Containerizing and Testing</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-the-big-picture-our-system-architecture">The Big Picture: Our System Architecture</h2>
<p>Before we dive into the code, let's look at the architecture we're building. I will be using <a target="_blank" href="https://www.proxmox.com/en/products/proxmox-virtual-environment/overview">Proxmox Virtual Environment</a> to setup a server cluster just like you would have in a datacenter.</p>
<h3 id="heading-how-to-set-up-proxmox">How to Set Up Proxmox</h3>
<p><code>Proxmox Virtual Environment</code> is an open source platform for virtualization. It lets you manage multiple VMs, ccontainers and other clusters with ease. For instance, I turned my old gaming computer into a Proxmox server which lets me run more than 20 virtual machines on it at the same time, making it similar to my very own datacenter. This lets me experiment with distributed applications by simulating datacenter environments.</p>
<p>To setup your own cluster, all you need is an old computer. You can download the ISO image from <a target="_blank" href="https://www.proxmox.com/en/downloads">here</a> and boot from the USB drive. Once you install it, you can configure the host machine via a web browser on any other computer on the same network.</p>
<p>For example, my proxmox server is located at <code>10.0.0.108</code> and I can access it via the browser on my laptop.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1759194790299/35e9363f-b739-4085-a589-c1bafbac0504.png" alt="Example Proxmox cluster" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>We define all our virtual machines in our <code>main.tf</code> file. And run a simple command <code>terraform apply</code> to spin these servers up. For more reading on how to use Terraform with Proxmox, I recommend this <a target="_blank" href="https://spacelift.io/blog/terraform-proxmox-provider">blog post</a></p>
<p>Back to our use case, we’ll have a few virtual machines that will serve as different kinds of servers:</p>
<ol>
<li><p>A Load balancer</p>
</li>
<li><p>A Rate Limiter ( A Redis Cache )</p>
</li>
<li><p>Two Web Servers</p>
</li>
<li><p>A Postgres database</p>
</li>
<li><p>One Virtual Machine that will test the load by simulating hundreds of calls per minute.</p>
</li>
</ol>
<p>If all of this seems daunting, don’t worry too much about it. You don’t need to set all this up to follow along.</p>
<h3 id="heading-centralized-rate-limiter">Centralized Rate Limiter</h3>
<p>Since our application will run on multiple servers (or "nodes"), we can't store request counts in memory on each individual server. Why? Because each server would have its own separate count, and we wouldn't have a <em>global</em> rate limit.</p>
<p>The solution is to use a centralized data store that all our application nodes can access. This is where Redis comes in.</p>
<p>Here’s a diagram of our setup:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1758476002871/1d70ce5b-e19c-4d7d-9c0b-cc18840a07bf.png" alt="A Small diagram depicting the architecture we'll form with all these virtual nodes" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<ol>
<li><p>User requests first hit our Nginx load balancer.</p>
</li>
<li><p>The load balancer distributes the traffic evenly between our two web server VMs. The configuration is simple, using an upstream block to define the servers.</p>
</li>
<li><p>Each web server runs our Python Flask application inside a Docker container.</p>
</li>
<li><p>Before processing any request, the Flask app communicates with the central Redis rate limiter VM to check if the user has exceeded the rate limit.</p>
</li>
<li><p>If the user is within the limit, the app processes the request and interacts with the PostgreSQL Database. If they're over the limit, it sends back a “429 Too Many Requests” error.</p>
</li>
</ol>
<p>This architecture ensures that no matter which web server handles the request, the rate limit is checked against the same, shared data source.</p>
<h2 id="heading-step-1-how-to-define-the-infrastructure-with-terraform"><strong>Step 1: How to Define the Infrastructure with Terraform</strong></h2>
<p>Manually setting up multiple virtual machines can be tedious and prone to errors. That's why we use Terraform, an Infrastructure as Code (IaC) tool. It lets us define our entire infrastructure in configuration files.</p>
<p><strong>Note</strong>: You can skip this section if you just want to see the rate limiter in action and how it’s used.</p>
<p>Our <a target="_blank" href="https://github.com/sravankaruturi/system-design/blob/main/infra/main.tf">main.tf</a> file defines all the components of our system. Let's look at a key piece: the Redis VM.</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># --- Redis Cache for Rate Limiter ---</span>
<span class="hljs-string">resource</span> <span class="hljs-string">"proxmox_vm_qemu"</span> <span class="hljs-string">"redis_cache"</span> {

    <span class="hljs-string">vmid</span>        <span class="hljs-string">=</span> <span class="hljs-number">130</span>
    <span class="hljs-string">name</span>        <span class="hljs-string">=</span> <span class="hljs-string">"redis-cache-rate-limiter"</span>
    <span class="hljs-string">target_node</span> <span class="hljs-string">=</span> <span class="hljs-string">"pve"</span>
    <span class="hljs-string">agent</span>       <span class="hljs-string">=</span> <span class="hljs-number">1</span>
    <span class="hljs-string">cores</span>       <span class="hljs-string">=</span> <span class="hljs-number">1</span>
    <span class="hljs-string">memory</span>      <span class="hljs-string">=</span> <span class="hljs-number">1024</span>
    <span class="hljs-comment"># ... cloud-init config ...</span>
    <span class="hljs-string">ipconfig0</span>  <span class="hljs-string">=</span> <span class="hljs-string">"ip=10.0.0.130/24,gw=10.0.0.1"</span>
    <span class="hljs-comment"># ... disk and network config ...</span>

    <span class="hljs-comment"># 1. Install Docker</span>
    <span class="hljs-string">provisioner</span> <span class="hljs-string">"remote-exec"</span> {
        <span class="hljs-string">inline</span> <span class="hljs-string">=</span> [
            <span class="hljs-string">"sleep 30; sudo apt-get update -y"</span>,
            <span class="hljs-string">"sudo apt-get install -y docker.io docker-compose"</span>,
            <span class="hljs-string">"sudo mkdir -p /opt/redis"</span>
        ]
    }

    <span class="hljs-comment"># 2. Upload docker-compose file</span>
    <span class="hljs-string">provisioner</span> <span class="hljs-string">"file"</span> {
         <span class="hljs-string">source</span>      <span class="hljs-string">=</span> <span class="hljs-string">"files/redis-docker-compose.yml"</span>
         <span class="hljs-string">destination</span> <span class="hljs-string">=</span> <span class="hljs-string">"/home/${var.ssh_user}/docker-compose.yml"</span>
    }

    <span class="hljs-comment"># 3. Move file and run docker-compose</span>
    <span class="hljs-string">provisioner</span> <span class="hljs-string">"remote-exec"</span> {
        <span class="hljs-string">inline</span> <span class="hljs-string">=</span> [
            <span class="hljs-string">"sudo mv /home/${var.ssh_user}/docker-compose.yml /opt/redis/docker-compose.yml"</span>,
            <span class="hljs-string">"cd /opt/redis &amp;&amp; sudo docker-compose up -d"</span>
        ]
    }
}
</code></pre>
<p>This block tells Terraform to create a <code>Proxmox QEMU virtual machine</code> with a specific IP address <code>(10.0.0.130)</code>. After the VM is created, it uses provisioners to connect via SSH and run commands. Here, it installs Docker, uploads our <code>redis-docker-compose.yml file</code>, and starts the Redis container.</p>
<p>The <code>redis-docker-compose.yml</code> itself is very straightforward:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">version:</span> <span class="hljs-string">'3.8'</span>
<span class="hljs-attr">services:</span>
  <span class="hljs-attr">redis:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">redis:latest</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">redis_cache</span>
    <span class="hljs-attr">restart:</span> <span class="hljs-string">always</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"6379:6379"</span>
    <span class="hljs-attr">volumes:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">redisdata:/data</span>

<span class="hljs-attr">volumes:</span>
  <span class="hljs-attr">redisdata:</span>
</code></pre>
<p>This ensures we have a persistent, containerized Redis instance ready to serve our application. The Terraform configuration similarly defines our web servers, load balancer, and databases.</p>
<h2 id="heading-step-2-how-to-implement-the-rate-limiter-logic-in-python"><strong>Step 2: How to Implement the Rate Limiter Logic in Python</strong></h2>
<p>Now, for the heart of our system: the Python code that implements the rate limiting logic. We're using a sophisticated and memory-efficient algorithm called the Sliding Window Log.</p>
<p>The idea is simple: for each user, we keep a log of the timestamps of their recent requests. We store this log in a Redis Sorted Set.</p>
<p>Let's break down the code from <a target="_blank" href="https://github.com/sravankaruturi/system-design/blob/main/web-servers/app.py"><code>app.py</code></a>.</p>
<h3 id="heading-the-flask-appbeforerequest-hook"><strong>The Flask</strong> <code>@app.before_request</code> <strong>Hook</strong></h3>
<p>Flask allows us to run code before any request is handled by its intended view function. This is the perfect place to put our rate limiter.</p>
<pre><code class="lang-python"><span class="hljs-keyword">import</span> psycopg2
<span class="hljs-keyword">import</span> string
<span class="hljs-keyword">import</span> random
<span class="hljs-keyword">import</span> redis
<span class="hljs-keyword">import</span> time
<span class="hljs-keyword">from</span> flask <span class="hljs-keyword">import</span> Flask, request, redirect, jsonify

app = Flask(__name__)

<span class="hljs-comment"># --- Database Connection Details ---</span>
DB_HOST = <span class="hljs-string">"10.0.0.200"</span> 
DB_NAME = <span class="hljs-string">"urldb"</span>
DB_USER = <span class="hljs-string">"myuser"</span>
DB_PASS = <span class="hljs-string">"mypassword"</span>

REDIS_HOST = <span class="hljs-string">"10.0.0.130"</span> <span class="hljs-comment"># IP of your redis-cache-lxc</span>

<span class="hljs-comment"># --- Rate Limiter Settings ---</span>
RATE_LIMIT_COUNT = <span class="hljs-number">10</span>  <span class="hljs-comment"># 10 requests</span>
RATE_LIMIT_WINDOW = <span class="hljs-number">60</span> <span class="hljs-comment"># per 60 seconds</span>

<span class="hljs-comment"># Establish a reusable Redis connection</span>
redis_client = redis.Redis(host=REDIS_HOST, port=<span class="hljs-number">6379</span>, decode_responses=<span class="hljs-literal">True</span>)

<span class="hljs-meta">@app.before_request</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">rate_limiter</span>():</span>
    <span class="hljs-comment"># Use the user's IP address as the key</span>
    <span class="hljs-comment"># In a real app, you'd handle proxies via request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr)</span>
    key = <span class="hljs-string">f"rate_limit:<span class="hljs-subst">{request.remote_addr}</span>"</span>
    now = time.time()

    <span class="hljs-comment"># Use a Redis pipeline for atomic operations</span>
    pipe = redis_client.pipeline()
    <span class="hljs-comment"># 1. Add current request timestamp. The score and member are the same.</span>
    pipe.zadd(key, {str(now): now})
    <span class="hljs-comment"># 2. Remove all timestamps older than our window</span>
    pipe.zremrangebyscore(key, <span class="hljs-number">0</span>, now - RATE_LIMIT_WINDOW)
    <span class="hljs-comment"># 3. Get the count of remaining timestamps</span>
    pipe.zcard(key)
    <span class="hljs-comment"># 4. Set an expiration on the key so it cleans itself up</span>
    pipe.expire(key, RATE_LIMIT_WINDOW)

    <span class="hljs-comment"># Execute the pipeline and get the results</span>
    results = pipe.execute()
    request_count = results[<span class="hljs-number">2</span>] <span class="hljs-comment"># The result of the zcard command</span>

    <span class="hljs-keyword">if</span> request_count &gt; RATE_LIMIT_COUNT:
        <span class="hljs-comment"># Return a 429 Too Many Requests error</span>
        <span class="hljs-keyword">return</span> jsonify(error=<span class="hljs-string">"Rate limit exceeded"</span>), <span class="hljs-number">429</span>

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">get_db_connection</span>():</span>
    conn = psycopg2.connect(host=DB_HOST, dbname=DB_NAME, user=DB_USER, password=DB_PASS)
    <span class="hljs-keyword">return</span> conn

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">init_db</span>():</span>
    conn = get_db_connection()
    cur = conn.cursor()
    cur.execute(<span class="hljs-string">'''
        CREATE TABLE IF NOT EXISTS urls (
            id SERIAL PRIMARY KEY,
            short_code VARCHAR(6) UNIQUE NOT NULL,
            original_url TEXT NOT NULL
        );
    '''</span>)
    <span class="hljs-comment"># Check if the index exists before creating it</span>
    cur.execute(<span class="hljs-string">'''
        SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
        WHERE c.relname = 'idx_original_url' AND n.nspname = 'public';
    '''</span>)
    <span class="hljs-keyword">if</span> cur.fetchone() <span class="hljs-keyword">is</span> <span class="hljs-literal">None</span>:
        cur.execute(<span class="hljs-string">'CREATE INDEX idx_original_url ON urls (original_url);'</span>)
    conn.commit()
    cur.close()
    conn.close()

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">generate_short_code</span>(<span class="hljs-params">length=<span class="hljs-number">6</span></span>):</span>
    chars = string.ascii_letters + string.digits
    <span class="hljs-keyword">return</span> <span class="hljs-string">''</span>.join(random.choice(chars) <span class="hljs-keyword">for</span> _ <span class="hljs-keyword">in</span> range(length))

<span class="hljs-meta">@app.route("/", methods=['GET'])</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">index</span>():</span>
    <span class="hljs-keyword">return</span> <span class="hljs-string">"URL Shortener is running!\n"</span>, <span class="hljs-number">200</span>

<span class="hljs-meta">@app.route('/shorten', methods=['POST'])</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">shorten_url</span>():</span>
    original_url = request.form[<span class="hljs-string">'url'</span>]
    conn = get_db_connection()
    cur = conn.cursor()

    cur.execute(<span class="hljs-string">"SELECT short_code FROM urls WHERE original_url = %s"</span>, (original_url,))
    existing_url = cur.fetchone()

    <span class="hljs-keyword">if</span> existing_url:
        short_code = existing_url[<span class="hljs-number">0</span>]
    <span class="hljs-keyword">else</span>:
        short_code = generate_short_code()
        cur.execute(<span class="hljs-string">"INSERT INTO urls (short_code, original_url) VALUES (%s, %s)"</span>, (short_code, original_url))
        conn.commit()

    cur.close()
    conn.close()

    <span class="hljs-keyword">return</span> jsonify(short_url=<span class="hljs-string">f"/<span class="hljs-subst">{short_code}</span>"</span>)

<span class="hljs-meta">@app.route('/&lt;short_code&gt;')</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">redirect_to_url</span>(<span class="hljs-params">short_code</span>):</span>
    conn = get_db_connection()
    cur = conn.cursor()
    cur.execute(<span class="hljs-string">"SELECT original_url FROM urls WHERE short_code = %s"</span>, (short_code,))
    url_record = cur.fetchone()
    cur.close()
    conn.close()

    <span class="hljs-keyword">if</span> url_record:
        <span class="hljs-keyword">return</span> redirect(url_record[<span class="hljs-number">0</span>])
    <span class="hljs-keyword">else</span>:
        <span class="hljs-keyword">return</span> <span class="hljs-string">"URL not found"</span>, <span class="hljs-number">404</span>

<span class="hljs-keyword">if</span> __name__ == <span class="hljs-string">'__main__'</span>:
    init_db() 
    app.run(host=<span class="hljs-string">'0.0.0.0'</span>, port=<span class="hljs-number">5000</span>)
</code></pre>
<h3 id="heading-how-it-works-step-by-step"><strong>How It Works, Step-by-Step</strong></h3>
<ol>
<li><p><strong>Identify the User:</strong> We create a unique Redis key for each user based on their IP address: <code>rate_limit:1.2.3.4</code>.</p>
</li>
<li><p><strong>Use a Pipeline:</strong> Network latency can be a bottleneck. A Redis pipeline bundles multiple commands into a single request-response cycle. This is much more efficient than sending them one by one. It also ensures the sequence of commands runs without being interrupted by commands from other clients.</p>
</li>
<li><p><strong>Log the Current Request (ZADD):</strong> We add the current timestamp (as a Unix epoch) to a sorted set. We use the timestamp for both the "member" and the "score," which allows us to easily filter by time.</p>
</li>
<li><p><strong>Clean Up Old Requests (ZREMRANGEBYSCORE):</strong> This is the "sliding window" part. We remove any timestamps from the set that are older than our <code>RATE_LIMIT_WINDOW</code> (60 seconds). This efficiently discards requests that are no longer relevant to the current rate limit period.</p>
</li>
<li><p><strong>Count the Recent Requests (ZCARD):</strong> We get the cardinality (the number of items) in the set. After the previous step, this number is our count of requests within the last 60 seconds.</p>
</li>
<li><p><strong>Mark the current record to expire (EXPIRE):</strong> We set an expiration on the key itself. If a user stops making requests, Redis will automatically delete their rate limit data after 60 seconds, preventing memory from filling up with old keys.</p>
</li>
<li><p><strong>Execute and Check:</strong> The <code>pipe.execute()</code> command sends all our bundled commands to Redis. We then check the result of our ZCARD command. If the count exceeds our <code>RATE_LIMIT_COUNT</code>, we immediately return a 429 error.</p>
</li>
</ol>
<p>This approach is incredibly fast and efficient. All the heavy lifting is done inside Redis, which is optimized for these kinds of operations.</p>
<h2 id="heading-step-3-containerizing-and-testing"><strong>Step 3: Containerizing and Testing</strong></h2>
<p>To deploy our application consistently across multiple VMs, we use Docker. Our Dockerfile is standard for a Python application: it starts from a Python image, installs dependencies from <code>requirements.txt</code>, copies the application code, and defines the command to run the app.</p>
<p>But how do we know it works? We test it!</p>
<p>We use <code>k6</code>, a modern load testing tool, to simulate heavy traffic. Our test script, <code>rate-test.js</code>, is designed specifically to verify the rate limiter.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">import</span> http <span class="hljs-keyword">from</span> <span class="hljs-string">'k6/http'</span>;
<span class="hljs-keyword">import</span> { check, sleep } <span class="hljs-keyword">from</span> <span class="hljs-string">'k6'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> options = {
  <span class="hljs-attr">stages</span>: [
    <span class="hljs-comment">// Ramp up to 20 users. This is more than the 10 req/min limit</span>
    <span class="hljs-comment">// and should trigger the rate limiter.</span>
    { <span class="hljs-attr">duration</span>: <span class="hljs-string">'30s'</span>, <span class="hljs-attr">target</span>: <span class="hljs-number">20</span> },
    { <span class="hljs-attr">duration</span>: <span class="hljs-string">'1m'</span>, <span class="hljs-attr">target</span>: <span class="hljs-number">20</span> },
    { <span class="hljs-attr">duration</span>: <span class="hljs-string">'10s'</span>, <span class="hljs-attr">target</span>: <span class="hljs-number">0</span> },
  ],
};

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-function"><span class="hljs-keyword">function</span> (<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">const</span> url = <span class="hljs-string">'http://10.0.0.100/shorten'</span>; <span class="hljs-comment">// The Load Balancer IP</span>
  <span class="hljs-keyword">const</span> payload = { <span class="hljs-attr">url</span>: <span class="hljs-string">`https://www.test-ratelimit-<span class="hljs-subst">${<span class="hljs-built_in">Math</span>.random()}</span>.com`</span> };

  <span class="hljs-keyword">const</span> res = http.post(url, payload);

  <span class="hljs-comment">// Check if the request was successful OR if it was correctly rate-limited</span>
  check(res, {
    <span class="hljs-string">'status is 200 (OK)'</span>: <span class="hljs-function">(<span class="hljs-params">r</span>) =&gt;</span> r.status === <span class="hljs-number">200</span>,
    <span class="hljs-string">'status is 429 (Too Many Requests)'</span>: <span class="hljs-function">(<span class="hljs-params">r</span>) =&gt;</span> r.status === <span class="hljs-number">429</span>,
  });

  sleep(<span class="hljs-number">1</span>);
}
</code></pre>
<p>The stages array configures the test to gradually increase the number of virtual users to 20. Since our rate limit is 10 requests per minute, this load is guaranteed to trigger the limiter.</p>
<p>The <code>check</code> function is the crucial part. It verifies that the server's response code is either 200 (meaning the request was successful) or 429 (meaning our rate limiter correctly blocked the request).</p>
<p>We should see about 10 of our requests go through of the around 1600 requests per minute that we send from the same IP address.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1758477504110/3a2f3f0f-8db0-453d-8900-42a6d0966a11.gif" alt="A gif showing the test run of the load testing script" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>We can also check the logs on our webserver to see all the requests that were sent to it.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1758477959201/80a39d07-1c4e-4d45-8a42-9ac2ce6f360d.gif" alt="A small gif demonstrating Web Server Logs" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>And if we look at the Redis cache/database itself, we’ll see all the keys and the TTL at which they expire.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1758478780827/6a07a2ee-0ad0-4b60-899f-d6a0453edbe7.png" alt="6a07a2ee-0ad0-4b60-899f-d6a0453edbe7" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>This is how we rate limit applications using a Redis Cache Server.</p>
<p>Here are the complete files used in the project.</p>
<pre><code class="lang-yaml">    <span class="hljs-string">terraform</span> {
    <span class="hljs-string">required_providers</span> {
        <span class="hljs-string">proxmox</span> <span class="hljs-string">=</span> {
        <span class="hljs-string">source</span>  <span class="hljs-string">=</span> <span class="hljs-string">"telmate/proxmox"</span>
        <span class="hljs-string">version</span> <span class="hljs-string">=</span> <span class="hljs-string">"3.0.2-rc04"</span>
        }
    }
    }

    <span class="hljs-string">provider</span> <span class="hljs-string">"proxmox"</span> {
    <span class="hljs-string">pm_api_url</span>          <span class="hljs-string">=</span> <span class="hljs-string">var.proxmox_api_url</span>
    <span class="hljs-string">pm_api_token_id</span>     <span class="hljs-string">=</span> <span class="hljs-string">var.proxmox_api_token_id</span>
    <span class="hljs-string">pm_api_token_secret</span> <span class="hljs-string">=</span> <span class="hljs-string">var.proxmox_api_token_secret</span>
    <span class="hljs-string">pm_tls_insecure</span>     <span class="hljs-string">=</span> <span class="hljs-literal">true</span>
    }

    <span class="hljs-comment"># --- Shared Provisioner Connection Settings ---</span>
    <span class="hljs-string">locals</span> {
        <span class="hljs-string">connection_settings</span> <span class="hljs-string">=</span> {
            <span class="hljs-string">type</span>        <span class="hljs-string">=</span> <span class="hljs-string">"ssh"</span>
            <span class="hljs-string">user</span>        <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_user</span>
            <span class="hljs-string">private_key</span> <span class="hljs-string">=</span> <span class="hljs-string">file(var.ssh_private_key_path)</span>
        }
    }

    <span class="hljs-comment"># --- Database LXC Containers ---</span>
    <span class="hljs-string">resource</span> <span class="hljs-string">"proxmox_lxc"</span> <span class="hljs-string">"postgres_db"</span> {
    <span class="hljs-string">hostname</span>     <span class="hljs-string">=</span> <span class="hljs-string">"postgres-db-lxc"</span>
    <span class="hljs-string">target_node</span>  <span class="hljs-string">=</span> <span class="hljs-string">var.target_node</span>
    <span class="hljs-string">ostemplate</span>   <span class="hljs-string">=</span> <span class="hljs-string">var.lxc_template</span>

    <span class="hljs-string">rootfs</span> {
        <span class="hljs-string">storage</span> <span class="hljs-string">=</span> <span class="hljs-string">"local-lvm"</span>
        <span class="hljs-string">size</span> <span class="hljs-string">=</span> <span class="hljs-string">"8G"</span>
    }

    <span class="hljs-string">password</span>     <span class="hljs-string">=</span> <span class="hljs-string">"admin"</span>
    <span class="hljs-string">unprivileged</span> <span class="hljs-string">=</span> <span class="hljs-literal">true</span>
    <span class="hljs-string">start</span>        <span class="hljs-string">=</span> <span class="hljs-literal">true</span>

    <span class="hljs-string">features</span> {
        <span class="hljs-string">nesting</span> <span class="hljs-string">=</span> <span class="hljs-literal">true</span>
        <span class="hljs-comment"># keyctl = true</span>
    }

    <span class="hljs-string">network</span> {
        <span class="hljs-string">name</span>   <span class="hljs-string">=</span> <span class="hljs-string">"eth0"</span>
        <span class="hljs-string">bridge</span> <span class="hljs-string">=</span> <span class="hljs-string">"vmbr0"</span>
        <span class="hljs-string">ip</span>     <span class="hljs-string">=</span> <span class="hljs-string">"10.0.0.200/24"</span>
        <span class="hljs-string">gw</span>     <span class="hljs-string">=</span> <span class="hljs-string">"10.0.0.1"</span>
    }

    <span class="hljs-string">provisioner</span> <span class="hljs-string">"remote-exec"</span> {
        <span class="hljs-string">connection</span> {
        <span class="hljs-string">type</span>        <span class="hljs-string">=</span> <span class="hljs-string">"ssh"</span>
        <span class="hljs-string">user</span>        <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_user</span>
        <span class="hljs-string">private_key</span> <span class="hljs-string">=</span> <span class="hljs-string">file(var.ssh_private_key_path)</span>
        <span class="hljs-string">host</span>        <span class="hljs-string">=</span> <span class="hljs-string">split("/"</span>, <span class="hljs-string">self.network</span>[<span class="hljs-number">0</span>]<span class="hljs-string">.ip)</span>[<span class="hljs-number">0</span>]
        }
        <span class="hljs-string">inline</span> <span class="hljs-string">=</span> [
        <span class="hljs-string">"sudo apt-get update"</span>,
        <span class="hljs-string">"sudo apt-get install -y docker.io docker-compose python3-setuptools"</span>,
        <span class="hljs-string">"sudo usermod -aG docker ${var.ssh_user}"</span>,
        <span class="hljs-string">"sudo mkdir -p /opt/postgres"</span>,
        <span class="hljs-string">"sudo chown ${var.ssh_user}:${var.ssh_user} /opt/postgres"</span>
        ]
    }

    <span class="hljs-string">provisioner</span> <span class="hljs-string">"file"</span> {
        <span class="hljs-string">connection</span> {
        <span class="hljs-string">type</span>        <span class="hljs-string">=</span> <span class="hljs-string">"ssh"</span>
        <span class="hljs-string">user</span>        <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_user</span>
        <span class="hljs-string">private_key</span> <span class="hljs-string">=</span> <span class="hljs-string">file(var.ssh_private_key_path)</span>
        <span class="hljs-string">host</span>        <span class="hljs-string">=</span> <span class="hljs-string">split("/"</span>, <span class="hljs-string">self.network</span>[<span class="hljs-number">0</span>]<span class="hljs-string">.ip)</span>[<span class="hljs-number">0</span>]
        }
        <span class="hljs-string">source</span>      <span class="hljs-string">=</span> <span class="hljs-string">"../databases/pg-docker-compose.yml"</span>
        <span class="hljs-string">destination</span> <span class="hljs-string">=</span> <span class="hljs-string">"/opt/postgres/docker-compose.yml"</span>
    }

    <span class="hljs-string">provisioner</span> <span class="hljs-string">"remote-exec"</span> {
        <span class="hljs-string">inline</span>     <span class="hljs-string">=</span> [<span class="hljs-string">"cd /opt/postgres &amp;&amp; sudo docker-compose up -d"</span>]

        <span class="hljs-string">connection</span> {
        <span class="hljs-string">type</span>        <span class="hljs-string">=</span> <span class="hljs-string">"ssh"</span>
        <span class="hljs-string">user</span>        <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_user</span>
        <span class="hljs-string">private_key</span> <span class="hljs-string">=</span> <span class="hljs-string">file(var.ssh_private_key_path)</span>
        <span class="hljs-string">host</span>        <span class="hljs-string">=</span> <span class="hljs-string">split("/"</span>, <span class="hljs-string">self.network</span>[<span class="hljs-number">0</span>]<span class="hljs-string">.ip)</span>[<span class="hljs-number">0</span>]
        }
    }
    }

    <span class="hljs-string">resource</span> <span class="hljs-string">"proxmox_lxc"</span> <span class="hljs-string">"mongo_db"</span> {
        <span class="hljs-string">hostname</span>    <span class="hljs-string">=</span> <span class="hljs-string">"mongo-db-lxc"</span>
        <span class="hljs-string">target_node</span> <span class="hljs-string">=</span> <span class="hljs-string">var.target_node</span>
        <span class="hljs-string">ostemplate</span>  <span class="hljs-string">=</span> <span class="hljs-string">var.lxc_template</span>

        <span class="hljs-string">rootfs</span> {
            <span class="hljs-string">storage</span> <span class="hljs-string">=</span> <span class="hljs-string">"local-lvm"</span>
            <span class="hljs-string">size</span> <span class="hljs-string">=</span> <span class="hljs-string">"8G"</span>
        }

        <span class="hljs-string">password</span>    <span class="hljs-string">=</span> <span class="hljs-string">"admin"</span>
        <span class="hljs-string">unprivileged</span> <span class="hljs-string">=</span> <span class="hljs-literal">true</span>
        <span class="hljs-string">start</span>       <span class="hljs-string">=</span> <span class="hljs-literal">true</span>

        <span class="hljs-string">features</span> {
            <span class="hljs-string">nesting</span> <span class="hljs-string">=</span> <span class="hljs-literal">true</span>
        <span class="hljs-comment"># keyctl = true # Somehow this is blocking the apply command</span>
        }

        <span class="hljs-string">network</span> {
            <span class="hljs-string">name</span>   <span class="hljs-string">=</span> <span class="hljs-string">"eth0"</span>
            <span class="hljs-string">bridge</span> <span class="hljs-string">=</span> <span class="hljs-string">"vmbr0"</span>
            <span class="hljs-string">ip</span>     <span class="hljs-string">=</span> <span class="hljs-string">"10.0.0.210/24"</span>
            <span class="hljs-string">gw</span>     <span class="hljs-string">=</span> <span class="hljs-string">"10.0.0.1"</span>
        }

        <span class="hljs-comment"># Provisioners similar to postgres_db</span>
        <span class="hljs-string">provisioner</span> <span class="hljs-string">"remote-exec"</span> {
            <span class="hljs-string">connection</span> {
                <span class="hljs-string">type</span>        <span class="hljs-string">=</span> <span class="hljs-string">"ssh"</span>
                <span class="hljs-string">user</span>        <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_user</span>
                <span class="hljs-string">private_key</span> <span class="hljs-string">=</span> <span class="hljs-string">file(var.ssh_private_key_path)</span>
                <span class="hljs-string">host</span>        <span class="hljs-string">=</span> <span class="hljs-string">split("/"</span>, <span class="hljs-string">self.network</span>[<span class="hljs-number">0</span>]<span class="hljs-string">.ip)</span>[<span class="hljs-number">0</span>]
            }
            <span class="hljs-string">inline</span> <span class="hljs-string">=</span> [
            <span class="hljs-string">"sudo apt-get update"</span>,
            <span class="hljs-string">"sudo apt-get install -y docker.io docker-compose python3-setuptools"</span>,
            <span class="hljs-string">"sudo usermod -aG docker ${var.ssh_user}"</span>,
            <span class="hljs-string">"sudo mkdir -p /opt/mongo"</span>,
            <span class="hljs-string">"sudo chown ${var.ssh_user}:${var.ssh_user} /opt/mongo"</span>
            ]
        }

        <span class="hljs-string">provisioner</span> <span class="hljs-string">"file"</span> {
            <span class="hljs-string">connection</span> {
            <span class="hljs-string">type</span>        <span class="hljs-string">=</span> <span class="hljs-string">"ssh"</span>
            <span class="hljs-string">user</span>        <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_user</span>
            <span class="hljs-string">private_key</span> <span class="hljs-string">=</span> <span class="hljs-string">file(var.ssh_private_key_path)</span>
            <span class="hljs-string">host</span>        <span class="hljs-string">=</span> <span class="hljs-string">split("/"</span>, <span class="hljs-string">self.network</span>[<span class="hljs-number">0</span>]<span class="hljs-string">.ip)</span>[<span class="hljs-number">0</span>]
            }
            <span class="hljs-string">source</span>      <span class="hljs-string">=</span> <span class="hljs-string">"../databases/mongo-docker-compose.yml"</span>
            <span class="hljs-string">destination</span> <span class="hljs-string">=</span> <span class="hljs-string">"/opt/mongo/docker-compose.yml"</span>
        }

        <span class="hljs-string">provisioner</span> <span class="hljs-string">"remote-exec"</span> {
            <span class="hljs-string">connection</span> {
            <span class="hljs-string">type</span>        <span class="hljs-string">=</span> <span class="hljs-string">"ssh"</span>
            <span class="hljs-string">user</span>        <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_user</span>
            <span class="hljs-string">private_key</span> <span class="hljs-string">=</span> <span class="hljs-string">file(var.ssh_private_key_path)</span>
            <span class="hljs-string">host</span>        <span class="hljs-string">=</span> <span class="hljs-string">split("/"</span>, <span class="hljs-string">self.network</span>[<span class="hljs-number">0</span>]<span class="hljs-string">.ip)</span>[<span class="hljs-number">0</span>]
            }
            <span class="hljs-string">inline</span>     <span class="hljs-string">=</span> [<span class="hljs-string">"cd /opt/mongo &amp;&amp; docker-compose up -d"</span>]
        }
    }

    <span class="hljs-comment"># --- Redis Cache for Rate Limiter ---</span>
    <span class="hljs-string">resource</span> <span class="hljs-string">"proxmox_vm_qemu"</span> <span class="hljs-string">"redis_cache"</span> {

        <span class="hljs-string">vmid</span>        <span class="hljs-string">=</span> <span class="hljs-number">130</span>
        <span class="hljs-string">name</span>        <span class="hljs-string">=</span> <span class="hljs-string">"redis-cache-rate-limiter"</span>
        <span class="hljs-string">target_node</span> <span class="hljs-string">=</span> <span class="hljs-string">"pve"</span>
        <span class="hljs-string">agent</span>       <span class="hljs-string">=</span> <span class="hljs-number">1</span>
        <span class="hljs-string">cpu</span> {
            <span class="hljs-string">cores</span>       <span class="hljs-string">=</span> <span class="hljs-number">1</span>
        }

        <span class="hljs-string">memory</span>      <span class="hljs-string">=</span> <span class="hljs-number">1024</span>
        <span class="hljs-string">boot</span>        <span class="hljs-string">=</span> <span class="hljs-string">"order=scsi0"</span> <span class="hljs-comment"># has to be the same as the OS disk of the template</span>
        <span class="hljs-string">clone</span>       <span class="hljs-string">=</span> <span class="hljs-string">"debian12-cloudinit"</span> <span class="hljs-comment"># The name of the template</span>
        <span class="hljs-string">scsihw</span>      <span class="hljs-string">=</span> <span class="hljs-string">"virtio-scsi-single"</span>
        <span class="hljs-string">vm_state</span>    <span class="hljs-string">=</span> <span class="hljs-string">"running"</span>
        <span class="hljs-string">automatic_reboot</span> <span class="hljs-string">=</span> <span class="hljs-literal">true</span>

        <span class="hljs-comment"># Cloud-Init configuration</span>
        <span class="hljs-string">cicustom</span>   <span class="hljs-string">=</span> <span class="hljs-string">"vendor=local:snippets/qemu-guest-agent.yml"</span> <span class="hljs-comment"># /var/lib/vz/snippets/qemu-guest-agent.yml</span>
        <span class="hljs-string">ciupgrade</span>  <span class="hljs-string">=</span> <span class="hljs-literal">true</span>
        <span class="hljs-string">nameserver</span> <span class="hljs-string">=</span> <span class="hljs-string">"1.1.1.1 8.8.8.8"</span>
        <span class="hljs-string">ipconfig0</span>  <span class="hljs-string">=</span> <span class="hljs-string">"ip=10.0.0.130/24,gw=10.0.0.1"</span>
        <span class="hljs-string">skip_ipv6</span>  <span class="hljs-string">=</span> <span class="hljs-literal">true</span>
        <span class="hljs-string">ciuser</span>     <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_user</span>
        <span class="hljs-string">cipassword</span> <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_password</span>
        <span class="hljs-string">sshkeys</span>    <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_key</span>

        <span class="hljs-comment"># Most cloud-init images require a serial device for their display</span>
        <span class="hljs-string">serial</span> {
            <span class="hljs-string">id</span> <span class="hljs-string">=</span> <span class="hljs-number">0</span>
        }

        <span class="hljs-string">disks</span> {
            <span class="hljs-string">scsi</span> {
            <span class="hljs-string">scsi0</span> {
                <span class="hljs-comment"># We have to specify the disk from our template, else Terraform will think it's not supposed to be there</span>
                <span class="hljs-string">disk</span> {
                <span class="hljs-string">storage</span> <span class="hljs-string">=</span> <span class="hljs-string">"local-lvm"</span>
                <span class="hljs-comment"># The size of the disk should be at least as big as the disk in the template. If it's smaller, the disk will be recreated</span>
                <span class="hljs-string">size</span>    <span class="hljs-string">=</span> <span class="hljs-string">"5G"</span> 
                }
            }
            }
            <span class="hljs-string">ide</span> {
            <span class="hljs-comment"># Some images require a cloud-init disk on the IDE controller, others on the SCSI or SATA controller</span>
            <span class="hljs-string">ide1</span> {
                <span class="hljs-string">cloudinit</span> {
                <span class="hljs-string">storage</span> <span class="hljs-string">=</span> <span class="hljs-string">"local-lvm"</span>
                }
            }
            }
        }

        <span class="hljs-string">network</span> {
            <span class="hljs-string">id</span> <span class="hljs-string">=</span> <span class="hljs-number">0</span>
            <span class="hljs-string">bridge</span> <span class="hljs-string">=</span> <span class="hljs-string">"vmbr0"</span>
            <span class="hljs-string">model</span>  <span class="hljs-string">=</span> <span class="hljs-string">"virtio"</span>
        }

        <span class="hljs-string">connection</span> {
            <span class="hljs-string">type</span>        <span class="hljs-string">=</span> <span class="hljs-string">"ssh"</span>
            <span class="hljs-string">user</span>        <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_user</span>
            <span class="hljs-string">private_key</span> <span class="hljs-string">=</span> <span class="hljs-string">file(var.ssh_private_key_path)</span>
            <span class="hljs-string">host</span>        <span class="hljs-string">=</span> <span class="hljs-string">"10.0.0.130"</span>
        }

        <span class="hljs-comment"># 1. Install Docker and create the final app directory</span>
        <span class="hljs-string">provisioner</span> <span class="hljs-string">"remote-exec"</span> {
            <span class="hljs-string">inline</span> <span class="hljs-string">=</span> [
                <span class="hljs-comment"># Wait for cloud-init to finish before doing anything else</span>
                <span class="hljs-string">"echo 'Waiting for cloud-init to finish...'"</span>,
                <span class="hljs-string">"while [ ! -f /var/lib/cloud/instance/boot-finished ]; do echo 'Still waiting...' &amp;&amp; sleep 1; done"</span>,
                <span class="hljs-string">"echo 'Cloud-init finished.'"</span>,

                <span class="hljs-comment"># Now, safely install packages</span>
                <span class="hljs-string">"sudo apt-get update -y"</span>,
                <span class="hljs-string">"sudo apt-get install -y docker.io docker-compose"</span>,
                <span class="hljs-string">"sudo mkdir -p /opt/redis"</span>,
            ]
        }

        <span class="hljs-string">provisioner</span> <span class="hljs-string">"file"</span> {
            <span class="hljs-string">source</span>      <span class="hljs-string">=</span> <span class="hljs-string">"../caching/redis-docker-compose.yml"</span>
            <span class="hljs-string">destination</span> <span class="hljs-string">=</span> <span class="hljs-string">"/home/${var.ssh_user}/docker-compose.yml"</span>
        }

        <span class="hljs-string">provisioner</span> <span class="hljs-string">"remote-exec"</span> {
            <span class="hljs-string">inline</span> <span class="hljs-string">=</span> [ <span class="hljs-string">"sudo mv /home/${var.ssh_user}/docker-compose.yml /opt/redis/docker-compose.yml"</span> ]
        }

        <span class="hljs-string">provisioner</span> <span class="hljs-string">"remote-exec"</span> {
            <span class="hljs-string">inline</span> <span class="hljs-string">=</span> [ <span class="hljs-string">"cd /opt/redis &amp;&amp; sudo docker-compose up -d"</span> ]
        }
    }

    <span class="hljs-string">resource</span> <span class="hljs-string">"proxmox_vm_qemu"</span> <span class="hljs-string">"web-servers"</span> {

        <span class="hljs-string">count</span> <span class="hljs-string">=</span> <span class="hljs-number">2</span>

        <span class="hljs-string">vmid</span>        <span class="hljs-string">=</span> <span class="hljs-string">count.index</span> <span class="hljs-string">+</span> <span class="hljs-number">150</span>
        <span class="hljs-string">name</span>        <span class="hljs-string">=</span> <span class="hljs-string">"web-server-tf-${count.index + 1}"</span>
        <span class="hljs-string">target_node</span> <span class="hljs-string">=</span> <span class="hljs-string">"pve"</span>
        <span class="hljs-string">agent</span>       <span class="hljs-string">=</span> <span class="hljs-number">1</span>
        <span class="hljs-string">cpu</span> {
            <span class="hljs-string">cores</span>       <span class="hljs-string">=</span> <span class="hljs-number">1</span>
        }
        <span class="hljs-string">memory</span>      <span class="hljs-string">=</span> <span class="hljs-number">1024</span>
        <span class="hljs-string">boot</span>        <span class="hljs-string">=</span> <span class="hljs-string">"order=scsi0"</span> <span class="hljs-comment"># has to be the same as the OS disk of the template</span>
        <span class="hljs-string">clone</span>       <span class="hljs-string">=</span> <span class="hljs-string">"debian12-cloudinit"</span> <span class="hljs-comment"># The name of the template</span>
        <span class="hljs-string">scsihw</span>      <span class="hljs-string">=</span> <span class="hljs-string">"virtio-scsi-single"</span>
        <span class="hljs-string">vm_state</span>    <span class="hljs-string">=</span> <span class="hljs-string">"running"</span>
        <span class="hljs-string">automatic_reboot</span> <span class="hljs-string">=</span> <span class="hljs-literal">true</span>

        <span class="hljs-comment"># Cloud-Init configuration</span>
        <span class="hljs-string">cicustom</span>   <span class="hljs-string">=</span> <span class="hljs-string">"vendor=local:snippets/qemu-guest-agent.yml"</span> <span class="hljs-comment"># /var/lib/vz/snippets/qemu-guest-agent.yml</span>
        <span class="hljs-string">ciupgrade</span>  <span class="hljs-string">=</span> <span class="hljs-literal">true</span>
        <span class="hljs-string">nameserver</span> <span class="hljs-string">=</span> <span class="hljs-string">"1.1.1.1 8.8.8.8"</span>
        <span class="hljs-string">ipconfig0</span>  <span class="hljs-string">=</span> <span class="hljs-string">"ip=10.0.0.${111 + count.index}/24,gw=10.0.0.1"</span>
        <span class="hljs-string">skip_ipv6</span>  <span class="hljs-string">=</span> <span class="hljs-literal">true</span>
        <span class="hljs-string">ciuser</span>     <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_user</span>
        <span class="hljs-string">cipassword</span> <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_password</span>
        <span class="hljs-string">sshkeys</span>    <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_key</span>

        <span class="hljs-comment"># Most cloud-init images require a serial device for their display</span>
        <span class="hljs-string">serial</span> {
            <span class="hljs-string">id</span> <span class="hljs-string">=</span> <span class="hljs-number">0</span>
        }

        <span class="hljs-string">disks</span> {
            <span class="hljs-string">scsi</span> {
            <span class="hljs-string">scsi0</span> {
                <span class="hljs-comment"># We have to specify the disk from our template, else Terraform will think it's not supposed to be there</span>
                <span class="hljs-string">disk</span> {
                <span class="hljs-string">storage</span> <span class="hljs-string">=</span> <span class="hljs-string">"local-lvm"</span>
                <span class="hljs-comment"># The size of the disk should be at least as big as the disk in the template. If it's smaller, the disk will be recreated</span>
                <span class="hljs-string">size</span>    <span class="hljs-string">=</span> <span class="hljs-string">"5G"</span> 
                }
            }
            }
            <span class="hljs-string">ide</span> {
            <span class="hljs-comment"># Some images require a cloud-init disk on the IDE controller, others on the SCSI or SATA controller</span>
            <span class="hljs-string">ide1</span> {
                <span class="hljs-string">cloudinit</span> {
                <span class="hljs-string">storage</span> <span class="hljs-string">=</span> <span class="hljs-string">"local-lvm"</span>
                }
            }
            }
        }

        <span class="hljs-string">network</span> {
            <span class="hljs-string">id</span> <span class="hljs-string">=</span> <span class="hljs-number">0</span>
            <span class="hljs-string">bridge</span> <span class="hljs-string">=</span> <span class="hljs-string">"vmbr0"</span>
            <span class="hljs-string">model</span>  <span class="hljs-string">=</span> <span class="hljs-string">"virtio"</span>
        }

        <span class="hljs-string">connection</span> {
            <span class="hljs-string">type</span>        <span class="hljs-string">=</span> <span class="hljs-string">"ssh"</span>
            <span class="hljs-string">user</span>        <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_user</span>
            <span class="hljs-string">private_key</span> <span class="hljs-string">=</span> <span class="hljs-string">file(var.ssh_private_key_path)</span>
            <span class="hljs-string">host</span>        <span class="hljs-string">=</span> <span class="hljs-string">"10.0.0.${111 + count.index}"</span>
        }

        <span class="hljs-comment"># 1. Install Docker and create the final app directory</span>
        <span class="hljs-string">provisioner</span> <span class="hljs-string">"remote-exec"</span> {
            <span class="hljs-string">inline</span> <span class="hljs-string">=</span> [
                <span class="hljs-comment"># Wait for cloud-init to finish before doing anything else</span>
                <span class="hljs-string">"echo 'Waiting for cloud-init to finish...'"</span>,
                <span class="hljs-string">"while [ ! -f /var/lib/cloud/instance/boot-finished ]; do echo 'Still waiting...' &amp;&amp; sleep 1; done"</span>,
                <span class="hljs-string">"echo 'Cloud-init finished.'"</span>,

                <span class="hljs-comment"># Now, safely install packages</span>
                <span class="hljs-string">"sudo apt-get update -y"</span>,
                <span class="hljs-string">"sudo apt-get install -y docker.io"</span>,
                <span class="hljs-string">"sudo mkdir -p /opt/app"</span>,
            ]
        }

        <span class="hljs-comment"># 2. Upload ONLY the necessary files to the user's home directory</span>
        <span class="hljs-string">provisioner</span> <span class="hljs-string">"file"</span> {
            <span class="hljs-string">source</span>      <span class="hljs-string">=</span> <span class="hljs-string">"../web-servers/app.py"</span>
            <span class="hljs-string">destination</span> <span class="hljs-string">=</span> <span class="hljs-string">"/home/${var.ssh_user}/app.py"</span>
        }
        <span class="hljs-string">provisioner</span> <span class="hljs-string">"file"</span> {
            <span class="hljs-string">source</span>      <span class="hljs-string">=</span> <span class="hljs-string">"../web-servers/Dockerfile"</span>
            <span class="hljs-string">destination</span> <span class="hljs-string">=</span> <span class="hljs-string">"/home/${var.ssh_user}/Dockerfile"</span>
        }
        <span class="hljs-string">provisioner</span> <span class="hljs-string">"file"</span> {
            <span class="hljs-string">source</span>      <span class="hljs-string">=</span> <span class="hljs-string">"../web-servers/requirements.txt"</span>
            <span class="hljs-string">destination</span> <span class="hljs-string">=</span> <span class="hljs-string">"/home/${var.ssh_user}/requirements.txt"</span>
        }

        <span class="hljs-comment"># 4. Move files from the home directory, build the image, and run the container</span>
        <span class="hljs-string">provisioner</span> <span class="hljs-string">"remote-exec"</span> {
            <span class="hljs-string">inline</span> <span class="hljs-string">=</span> [
                <span class="hljs-comment"># Move each file individually to be compatible with all shells</span>
                <span class="hljs-string">"sudo mv /home/${var.ssh_user}/app.py /opt/app/"</span>,
                <span class="hljs-string">"sudo mv /home/${var.ssh_user}/Dockerfile /opt/app/"</span>,
                <span class="hljs-string">"sudo mv /home/${var.ssh_user}/requirements.txt /opt/app/"</span>,

                <span class="hljs-comment"># Build the Docker image</span>
                <span class="hljs-string">"sudo docker build -t my-python-app /opt/app"</span>,

                <span class="hljs-comment"># Stop and remove any old containers to prevent conflicts</span>
                <span class="hljs-string">"sudo docker stop $(sudo docker ps -q --filter ancestor=my-python-app) 2&gt;/dev/null || true"</span>,
                <span class="hljs-string">"sudo docker rm $(sudo docker ps -aq --filter ancestor=my-python-app) 2&gt;/dev/null || true"</span>,

                <span class="hljs-comment"># Run the new container</span>
                <span class="hljs-string">"sudo docker run -d --restart always -p 80:5000 my-python-app"</span>
            ]
        }

        <span class="hljs-comment"># In your proxmox_vm_qemu "web_servers" resource</span>
        <span class="hljs-string">depends_on</span> <span class="hljs-string">=</span> [
            <span class="hljs-string">proxmox_lxc.postgres_db</span>,
            <span class="hljs-string">proxmox_vm_qemu.redis_cache</span>
        ]
    }

    <span class="hljs-comment"># --- Load Balancer VM ---</span>
    <span class="hljs-string">resource</span> <span class="hljs-string">"proxmox_vm_qemu"</span> <span class="hljs-string">"load_balancer"</span> {
        <span class="hljs-string">name</span>        <span class="hljs-string">=</span> <span class="hljs-string">"lb-1"</span>
        <span class="hljs-string">target_node</span> <span class="hljs-string">=</span> <span class="hljs-string">var.target_node</span>
        <span class="hljs-string">clone</span>       <span class="hljs-string">=</span> <span class="hljs-string">var.vm_template</span>
        <span class="hljs-string">agent</span>       <span class="hljs-string">=</span> <span class="hljs-number">1</span>
        <span class="hljs-string">cpu</span> {
            <span class="hljs-string">cores</span>       <span class="hljs-string">=</span> <span class="hljs-number">1</span>
        }
        <span class="hljs-string">memory</span>      <span class="hljs-string">=</span> <span class="hljs-number">512</span>
        <span class="hljs-string">boot</span>        <span class="hljs-string">=</span> <span class="hljs-string">"order=scsi0"</span> <span class="hljs-comment"># has to be the same as the OS disk of the template</span>
        <span class="hljs-string">scsihw</span>      <span class="hljs-string">=</span> <span class="hljs-string">"virtio-scsi-single"</span>
        <span class="hljs-string">vm_state</span>    <span class="hljs-string">=</span> <span class="hljs-string">"running"</span>
        <span class="hljs-string">automatic_reboot</span> <span class="hljs-string">=</span> <span class="hljs-literal">true</span>

        <span class="hljs-comment"># --- Add these lines for Cloud Init Drive ---</span>
                <span class="hljs-comment"># --- Add these lines for Cloud Init Drive ---</span>
        <span class="hljs-string">cicustom</span>   <span class="hljs-string">=</span> <span class="hljs-string">"vendor=local:snippets/qemu-guest-agent.yml"</span> <span class="hljs-comment"># /var/lib/vz/snippets/qemu-guest-agent.yml</span>
        <span class="hljs-string">ciupgrade</span>  <span class="hljs-string">=</span> <span class="hljs-literal">true</span>
        <span class="hljs-string">nameserver</span> <span class="hljs-string">=</span> <span class="hljs-string">"1.1.1.1 8.8.8.8"</span>
        <span class="hljs-string">ipconfig0</span>  <span class="hljs-string">=</span> <span class="hljs-string">"ip=10.0.0.100/24,gw=10.0.0.1"</span>
        <span class="hljs-string">skip_ipv6</span>  <span class="hljs-string">=</span> <span class="hljs-literal">true</span>
        <span class="hljs-string">ciuser</span>     <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_user</span>
        <span class="hljs-string">cipassword</span> <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_password</span>
        <span class="hljs-string">sshkeys</span>    <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_key</span>

        <span class="hljs-comment"># Most cloud-init images require a serial device for their display</span>
        <span class="hljs-string">serial</span> {
            <span class="hljs-string">id</span> <span class="hljs-string">=</span> <span class="hljs-number">0</span>
        }

        <span class="hljs-string">disks</span> {
            <span class="hljs-string">scsi</span> {
            <span class="hljs-string">scsi0</span> {
                <span class="hljs-comment"># We have to specify the disk from our template, else Terraform will think it's not supposed to be there</span>
                <span class="hljs-string">disk</span> {
                <span class="hljs-string">storage</span> <span class="hljs-string">=</span> <span class="hljs-string">"local-lvm"</span>
                <span class="hljs-comment"># The size of the disk should be at least as big as the disk in the template. If it's smaller, the disk will be recreated</span>
                <span class="hljs-string">size</span>    <span class="hljs-string">=</span> <span class="hljs-string">"5G"</span> 
                }
            }
            }
            <span class="hljs-string">ide</span> {
            <span class="hljs-comment"># Some images require a cloud-init disk on the IDE controller, others on the SCSI or SATA controller</span>
            <span class="hljs-string">ide1</span> {
                <span class="hljs-string">cloudinit</span> {
                <span class="hljs-string">storage</span> <span class="hljs-string">=</span> <span class="hljs-string">"local-lvm"</span>
                }
            }
            }
        }

        <span class="hljs-string">network</span> {
            <span class="hljs-string">id</span> <span class="hljs-string">=</span> <span class="hljs-number">0</span>
            <span class="hljs-string">bridge</span> <span class="hljs-string">=</span> <span class="hljs-string">"vmbr0"</span>
            <span class="hljs-string">model</span>  <span class="hljs-string">=</span> <span class="hljs-string">"virtio"</span>
        }

        <span class="hljs-string">connection</span> {
            <span class="hljs-string">type</span>        <span class="hljs-string">=</span> <span class="hljs-string">"ssh"</span>
            <span class="hljs-string">user</span>        <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_user</span>
            <span class="hljs-string">private_key</span> <span class="hljs-string">=</span> <span class="hljs-string">file(var.ssh_private_key_path)</span>
            <span class="hljs-string">host</span>        <span class="hljs-string">=</span> <span class="hljs-string">"10.0.0.100"</span>
        }

        <span class="hljs-comment"># Step 1: Install Nginx</span>
        <span class="hljs-string">provisioner</span> <span class="hljs-string">"remote-exec"</span> {
            <span class="hljs-string">inline</span> <span class="hljs-string">=</span> [
                <span class="hljs-comment"># Wait for cloud-init to finish before doing anything else</span>
                <span class="hljs-string">"echo 'Waiting for cloud-init to finish...'"</span>,
                <span class="hljs-string">"while [ ! -f /var/lib/cloud/instance/boot-finished ]; do echo 'Still waiting...' &amp;&amp; sleep 1; done"</span>,
                <span class="hljs-string">"echo 'Cloud-init finished.'"</span>,

                <span class="hljs-comment"># Now, safely install packages</span>
                <span class="hljs-string">"sudo apt-get update -y"</span>,
                <span class="hljs-string">"sudo apt-get install -y nginx"</span>
            ]
        }

        <span class="hljs-comment"># Step 2: Upload config to a temporary location</span>
        <span class="hljs-string">provisioner</span> <span class="hljs-string">"file"</span> {
            <span class="hljs-string">source</span>      <span class="hljs-string">=</span> <span class="hljs-string">"../web-servers/nginx.conf"</span>
            <span class="hljs-string">destination</span> <span class="hljs-string">=</span> <span class="hljs-string">"/tmp/nginx.conf"</span> <span class="hljs-comment"># Use /tmp instead</span>
        }

        <span class="hljs-comment"># Step 3: Use sudo to move the file to its final destination and reload nginx</span>
        <span class="hljs-string">provisioner</span> <span class="hljs-string">"remote-exec"</span> {
            <span class="hljs-string">inline</span> <span class="hljs-string">=</span> [
                <span class="hljs-string">"sudo mv /tmp/nginx.conf /etc/nginx/sites-available/default"</span>,
                <span class="hljs-string">"sudo systemctl reload nginx"</span>
            ]
        }
    }


    <span class="hljs-comment"># --- Load Tester VM ---</span>
    <span class="hljs-string">resource</span> <span class="hljs-string">"proxmox_vm_qemu"</span> <span class="hljs-string">"load_tester"</span> {
        <span class="hljs-string">name</span>        <span class="hljs-string">=</span> <span class="hljs-string">"load-tester-vm"</span>
        <span class="hljs-string">target_node</span> <span class="hljs-string">=</span> <span class="hljs-string">var.target_node</span>
        <span class="hljs-string">clone</span>       <span class="hljs-string">=</span> <span class="hljs-string">var.vm_template</span>
        <span class="hljs-string">agent</span>       <span class="hljs-string">=</span> <span class="hljs-number">1</span>
        <span class="hljs-string">cpu</span> {
            <span class="hljs-string">cores</span>       <span class="hljs-string">=</span> <span class="hljs-number">1</span>
        }
        <span class="hljs-string">memory</span>      <span class="hljs-string">=</span> <span class="hljs-number">1024</span>
        <span class="hljs-string">boot</span>        <span class="hljs-string">=</span> <span class="hljs-string">"order=scsi0"</span> <span class="hljs-comment"># has to be the same as the OS disk of the template</span>
        <span class="hljs-string">scsihw</span>      <span class="hljs-string">=</span> <span class="hljs-string">"virtio-scsi-single"</span>
        <span class="hljs-string">vm_state</span>    <span class="hljs-string">=</span> <span class="hljs-string">"running"</span>
        <span class="hljs-string">automatic_reboot</span> <span class="hljs-string">=</span> <span class="hljs-literal">true</span>

        <span class="hljs-comment"># --- Add these lines for Cloud Init Drive ---</span>
        <span class="hljs-string">cicustom</span>   <span class="hljs-string">=</span> <span class="hljs-string">"vendor=local:snippets/qemu-guest-agent.yml"</span> <span class="hljs-comment"># /var/lib/vz/snippets/qemu-guest-agent.yml</span>
        <span class="hljs-string">ciupgrade</span>  <span class="hljs-string">=</span> <span class="hljs-literal">true</span>
        <span class="hljs-string">nameserver</span> <span class="hljs-string">=</span> <span class="hljs-string">"1.1.1.1 8.8.8.8"</span>
        <span class="hljs-string">ipconfig0</span>  <span class="hljs-string">=</span> <span class="hljs-string">"ip=10.0.0.160/24,gw=10.0.0.1"</span>
        <span class="hljs-string">skip_ipv6</span>  <span class="hljs-string">=</span> <span class="hljs-literal">true</span>
        <span class="hljs-string">ciuser</span>     <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_user</span>
        <span class="hljs-string">cipassword</span> <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_password</span>
        <span class="hljs-string">sshkeys</span>    <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_key</span>

        <span class="hljs-comment"># Most cloud-init images require a serial device for their display</span>
        <span class="hljs-string">serial</span> {
            <span class="hljs-string">id</span> <span class="hljs-string">=</span> <span class="hljs-number">0</span>
        }

        <span class="hljs-string">disks</span> {
            <span class="hljs-string">scsi</span> {
                <span class="hljs-string">scsi0</span> {
                    <span class="hljs-comment"># We have to specify the disk from our template, else Terraform will think it's not supposed to be there</span>
                    <span class="hljs-string">disk</span> {
                    <span class="hljs-string">storage</span> <span class="hljs-string">=</span> <span class="hljs-string">"local-lvm"</span>
                    <span class="hljs-comment"># The size of the disk should be at least as big as the disk in the template. If it's smaller, the disk will be recreated</span>
                    <span class="hljs-string">size</span>    <span class="hljs-string">=</span> <span class="hljs-string">"5G"</span> 
                    }
                }
            }

            <span class="hljs-string">ide</span> {
            <span class="hljs-comment"># Some images require a cloud-init disk on the IDE controller, others on the SCSI or SATA controller</span>
                <span class="hljs-string">ide1</span> {
                    <span class="hljs-string">cloudinit</span> {
                    <span class="hljs-string">storage</span> <span class="hljs-string">=</span> <span class="hljs-string">"local-lvm"</span>
                    }
                }
            }
        }

        <span class="hljs-string">network</span> {
            <span class="hljs-string">id</span> <span class="hljs-string">=</span> <span class="hljs-number">0</span>
            <span class="hljs-string">bridge</span> <span class="hljs-string">=</span> <span class="hljs-string">"vmbr0"</span>
            <span class="hljs-string">model</span>  <span class="hljs-string">=</span> <span class="hljs-string">"virtio"</span>
        }

        <span class="hljs-string">provisioner</span> <span class="hljs-string">"remote-exec"</span> {
            <span class="hljs-string">connection</span> {
                <span class="hljs-string">type</span>        <span class="hljs-string">=</span> <span class="hljs-string">"ssh"</span>
                <span class="hljs-string">user</span>        <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_user</span>
                <span class="hljs-string">private_key</span> <span class="hljs-string">=</span> <span class="hljs-string">file(var.ssh_private_key_path)</span>
                <span class="hljs-string">host</span>        <span class="hljs-string">=</span> <span class="hljs-string">"10.0.0.160"</span>
            }
            <span class="hljs-string">inline</span> <span class="hljs-string">=</span> [
                <span class="hljs-comment"># Wait for cloud-init to finish</span>
                <span class="hljs-string">"echo 'Waiting for cloud-init to finish...'"</span>,
                <span class="hljs-string">"while [ ! -f /var/lib/cloud/instance/boot-finished ]; do echo 'Still waiting...' &amp;&amp; sleep 1; done"</span>,
                <span class="hljs-string">"echo 'Cloud-init finished.'"</span>,

                <span class="hljs-comment"># Install prerequisites</span>
                <span class="hljs-string">"sudo apt-get update -y"</span>,
                <span class="hljs-string">"sudo apt-get install -y gnupg curl"</span>,

                <span class="hljs-comment"># Add the k6 repository and key</span>
                <span class="hljs-string">"curl -sL https://dl.k6.io/key.gpg | sudo gpg --dearmor -o /usr/share/keyrings/k6-archive-keyring.gpg"</span>,
                <span class="hljs-string">"echo 'deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main' | sudo tee /etc/apt/sources.list.d/k6.list"</span>,

                <span class="hljs-comment"># Install k6</span>
                <span class="hljs-string">"sudo apt-get update"</span>,
                <span class="hljs-string">"sudo apt-get install -y k6"</span>
            ]
        }

        <span class="hljs-string">provisioner</span> <span class="hljs-string">"file"</span> {
            <span class="hljs-string">connection</span> {
            <span class="hljs-string">type</span>        <span class="hljs-string">=</span> <span class="hljs-string">"ssh"</span>
            <span class="hljs-string">user</span>        <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_user</span>
            <span class="hljs-string">private_key</span> <span class="hljs-string">=</span> <span class="hljs-string">file(var.ssh_private_key_path)</span>
            <span class="hljs-string">host</span>        <span class="hljs-string">=</span> <span class="hljs-string">"10.0.0.160"</span>
            }
            <span class="hljs-string">source</span>      <span class="hljs-string">=</span> <span class="hljs-string">"../load-testing/script.js"</span>
            <span class="hljs-string">destination</span> <span class="hljs-string">=</span> <span class="hljs-string">"/home/${var.ssh_user}/script.js"</span>
        }

        <span class="hljs-string">provisioner</span> <span class="hljs-string">"file"</span> {
            <span class="hljs-string">connection</span> {
            <span class="hljs-string">type</span>        <span class="hljs-string">=</span> <span class="hljs-string">"ssh"</span>
            <span class="hljs-string">user</span>        <span class="hljs-string">=</span> <span class="hljs-string">var.ssh_user</span>
            <span class="hljs-string">private_key</span> <span class="hljs-string">=</span> <span class="hljs-string">file(var.ssh_private_key_path)</span>
            <span class="hljs-string">host</span>        <span class="hljs-string">=</span> <span class="hljs-string">"10.0.0.160"</span>
            }
            <span class="hljs-string">source</span>      <span class="hljs-string">=</span> <span class="hljs-string">"../load-testing/rate-test.js"</span>
            <span class="hljs-string">destination</span> <span class="hljs-string">=</span> <span class="hljs-string">"/home/${var.ssh_user}/rate-test.js"</span>
        }

    }
</code></pre>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>You've now seen how to build a complete, scalable, and resilient system that includes a crucial component for modern web applications: a distributed rate limiter.</p>
<p>We've covered the entire stack:</p>
<ul>
<li><p><strong>Infrastructure as Code</strong> with Terraform to define our virtual machines. (check out my repo <a target="_blank" href="https://github.com/sravankaruturi/system-design">here</a> for all the code and any updates I make).</p>
</li>
<li><p>A <strong>centralized, high-speed cache</strong> with Redis to store our rate limiting data.</p>
</li>
<li><p>An efficient <strong>Sliding Window Log algorithm</strong> implemented in Python with Flask.</p>
</li>
<li><p><strong>Containerization</strong> with Docker for consistent deployment.</p>
</li>
<li><p><strong>Load balancing</strong> with Nginx to distribute traffic.</p>
</li>
<li><p><strong>Load testing</strong> with k6 to validate our implementation.</p>
</li>
</ul>
<p>If you’d like to learn more of the concepts that are used when building large scale systems please follow me at <a class="user-mention" href="https://hashnode.com/@sravankaruturi">Sravan Karuturi</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Send Emails in Python using Mailtrap SMTP and the Email API ]]>
                </title>
                <description>
                    <![CDATA[ In this tutorial, I’ll walk you through the process of sending emails in Python using two different methods:  The traditional SMTP setup with the built-in ‘smtplib’ module.  Mailtrap email API via Mailtrap’s official SDK.  If you’re unfamiliar wi... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/send-emails-in-python-using-mailtrap-smtp-and-the-email-api/</link>
                <guid isPermaLink="false">67e6ab43aa64aee164e7985e</guid>
                
                    <category>
                        <![CDATA[ Phyton ]]>
                    </category>
                
                    <category>
                        <![CDATA[ smtp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mailtrap ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Alex Tray ]]>
                </dc:creator>
                <pubDate>Fri, 28 Mar 2025 13:59:31 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1743110284000/6fb2a037-ddca-4625-acfb-cffbd167ec55.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this tutorial, I’ll walk you through the process of sending emails in Python using two different methods: </p>
<ol>
<li><p>The traditional SMTP setup with the built-in ‘smtplib’ module. </p>
</li>
<li><p>Mailtrap email API via Mailtrap’s official SDK. </p>
</li>
</ol>
<p>If you’re unfamiliar with the tools and workflows, SMTP (Simple Mail Transfer Protocol) is the protocol commonly used for sending emails via apps and websites. Mailtrap is an email delivery platform designed for high deliverability with growth-focused features and industry-best analytics. </p>
<p>By the end of the article, you’ll understand how to integrate email-sending capabilities into Python projects and use Mailtrap for reliable email delivery in real-world scenarios.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a class="post-section-overview" href="#heading-smtplib-setup">'smtplib' Setup</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-send-emails-with-mailtrap-smtp">How to Send emails with Mailtrap SMTP</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-send-emails-with-the-mailtrap-email-api">How to Send emails with the Mailtrap Email API</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-smtplib-setup">‘smtplib’ Setup</h2>
<p>To start sending emails with Python, I'll first use the built-in ‘smtplib’ module. This lets you connect to an SMTP server and send emails directly from your app. </p>
<p>So, start by importing the ‘smtplib’ module with the statement below:</p>
<pre><code class="lang-python"><span class="hljs-keyword">import</span> smtplib
</code></pre>
<p>Next, create an ‘SMTP’ object to configure the connection to your SMTP server. This object handles the email sending. </p>
<pre><code class="lang-python">smtpObj = smtplib.SMTP(host, port)
</code></pre>
<ul>
<li><p>‘host’ refers to the SMTP server endpoint, such as ‘live.smtp.mailtrap.io’</p>
</li>
<li><p>‘port’ is the communication channel used by the server. The recommended port is usually 587 for secure email sending with TLS encryption. </p>
</li>
</ul>
<p><strong>Pro tip</strong>: An SMTP object has a ‘sendmail’ instance object with three parameters, where each parameter is a string (‘receivers’ is a list of strings). </p>
<pre><code class="lang-python">smtpObj.sendmail(sender, receivers, message)
</code></pre>
<p>If you want to ensure you’ve properly imported the ‘smtplib’ module and check the full description of arguments and classes, run the following command:</p>
<pre><code class="lang-python">help(smtplib)
</code></pre>
<h2 id="heading-how-to-send-emails-with-mailtrap-smtp">How to Send emails with Mailtrap SMTP</h2>
<p>This method involves setting up the custom SMTP credentials you get for Mailtrap.</p>
<p><strong>Important notes</strong>: </p>
<ul>
<li><p><strong>Testing out the service with Mailtrap’s dummy domain</strong> – To try Mailtrap, you don’t need to verify your domain right away. You can use Mailtrap’s dummy domain (you get access to it when you sign up), which allows you to simulate sending emails without worrying about the DNS records. This is ideal for testing the service and getting familiar with Mailtrap’s features.  </p>
</li>
<li><p><strong>Domain verification for production</strong> – If you plan to send real emails to recipients, you’ll need to verify your domain. This involves adding DNS records such as SPF, DKIM, and <a target="_blank" href="https://dmarcreport.com/">DMARC</a> to your domain provider’s DNS settings. These records ensure your emails are delivered successfully and help protect against phishing and spoofing. In the next section, I'll show you how to set these up in your domain provider's dashboard. </p>
</li>
</ul>
<h3 id="heading-verify-your-sending-domain-spf-dkim-and-dmarc">Verify your sending domain (SPF, DKIM, and DMARC)</h3>
<p>DNS records are critical to ensure your emails are delivered successfully, and mailbox providers such as Gmail and Yahoo require DNS authentication. </p>
<p>But before we go through a quick tutorial on how to do it, let’s review each type of record so you understand why they’re so important:</p>
<ul>
<li><p><strong>SPF (Sender Policy Framework)</strong>: The record helps mail servers determine if the sender’s IP address is authorized to send emails from your domain. Simply, adding an SPF record prevents spammers from sending emails that appear to come from your domain. </p>
</li>
<li><p><strong>DKIM (DomainKeys Identified Mail)</strong>: DKIM uses encryption to verify the sender's domain and ensures that the email content hasn't been tampered with during transmission. This protects your emails from being spoofed. </p>
</li>
<li><p><strong>DMARC (Domain-based Message Authentication, Reporting &amp; Conformance)</strong>: DMARC ties SPF and DKIM together, providing a policy for handling unauthenticated emails and reporting on email activities. In a nutshell, it gives you more control over your domain’s email security. </p>
</li>
</ul>
<p>Now, here’s how to add the records: </p>
<ol>
<li><p>First, you need to access your domain provider's DNS settings. Usually, you can access them in the domain register or domain settings. For example, GoDaddy calls the menu Manage DNS, and it's dubbed similarly with other providers. </p>
</li>
<li><p>Next, add (copy-paste) the DNS records Mailtrap provides into your domain provider's DNS settings. Note that Mailtrap's records are read-made, and SPF is pre-parsed, so you don't need to create anything additional – just add the records. </p>
</li>
</ol>
<p><img src="https://lh7-rt.googleusercontent.com/docsz/AD_4nXfHx2AAc87krxYh7twU5Ypuz-Iu6gklvJeVBzpdgptvfc7B9g7X3BBnqWai8n47HTDJrj1rZ2ny0jfscJJYgAAFcuEsZeVqYO2OellzvQgaXMjnMMxIeOoPGF0ildRbecEi7rjPbg?key=CJmzmKUWxlFjIw3A041wXvaj" alt="Screenshot showing domain verification" width="600" height="400" loading="lazy"></p>
<ol start="3">
<li>Finally, you can check the status of your records with Mailtrap. </li>
</ol>
<p>Below is the bare-bones script for sending emails via Mailtrap using Python. For security reasons, the script uses placeholder credentials for the username and password (except for the SMTP server endpoint and port).</p>
<p>When running the script, be sure to replace these placeholders with your actual Mailtrap credentials to ensure the email is sent successfully. </p>
<pre><code class="lang-python"><span class="hljs-keyword">import</span> smtplib
<span class="hljs-keyword">from</span> email.mime.text <span class="hljs-keyword">import</span> MIMEText

<span class="hljs-comment"># Configuration</span>
port = <span class="hljs-number">587</span>
smtp_server = <span class="hljs-string">"live.smtp.mailtrap.io"</span>
login = <span class="hljs-string">"api"</span>  <span class="hljs-comment"># Your login generated by Mailtrap</span>
password = <span class="hljs-string">"1a2b3c4d5e6f7g"</span>  <span class="hljs-comment"># Your password generated by Mailtrap</span>

sender_email = <span class="hljs-string">"mailtrap@example.com"</span>
receiver_email = <span class="hljs-string">"new@example.com"</span>

<span class="hljs-comment"># Plain text content</span>
text = <span class="hljs-string">"""\
Hi,
Check out the new post on the Mailtrap blog:
SMTP Server for Testing: Cloud-based or Local?
https://blog.mailtrap.io/2018/09/27/cloud-or-local-smtp-server/
Feel free to let us know what content would be useful for you!
"""</span>

<span class="hljs-comment"># Create MIMEText object</span>
message = MIMEText(text, <span class="hljs-string">"plain"</span>)
message[<span class="hljs-string">"Subject"</span>] = <span class="hljs-string">"Plain text email"</span>
message[<span class="hljs-string">"From"</span>] = sender_email
message[<span class="hljs-string">"To"</span>] = receiver_email

<span class="hljs-comment"># Send the email</span>
<span class="hljs-keyword">with</span> smtplib.SMTP(smtp_server, port) <span class="hljs-keyword">as</span> server:
    server.starttls()  <span class="hljs-comment"># Secure the connection</span>
    server.login(login, password)
    server.sendmail(sender_email, receiver_email, message.as_string())

print(<span class="hljs-string">'Sent'</span>)
</code></pre>
<p><strong>In the script</strong>:</p>
<ul>
<li><p>The ‘smtplib’ and ‘MIMEText’ modules have been imported from Python’s library. </p>
</li>
<li><p>As mentioned, SMTP server configuration needs to be updated with your credentials. But the server endpoint and port are as is. </p>
</li>
<li><p>Since this is a bare-bones script, I used ‘MIMEText’, which holds ‘plaintext’ only. But the script can be easily refactored to use ‘MIMEMultipart’ for both ‘plaintext’ and ‘HTML’. Jump to the quick tut below to see how it’s done. </p>
</li>
<li><p>When sending the email, I chose to use the ‘with’ statement (context manager) to ensure the SMTP server connection gets closed right after the email gets sent. </p>
</li>
</ul>
<p><strong>Security tip</strong>: </p>
<p>Server information and the login credentials shouldn't be hardcoded into your sending script. When setting the script for production, make sure you use environment variables to store sensitive information. This makes the code more secure and more flexible, particularly when you move it between different dev stages. For example ⬇️</p>
<pre><code class="lang-python"><span class="hljs-keyword">import</span> os

smtp_server = os.getenv(<span class="hljs-string">"SMTP_SERVER"</span>, <span class="hljs-string">"default.smtp.server"</span>)
login = os.getenv(<span class="hljs-string">"SMTP_LOGIN"</span>)
password = os.getenv(<span class="hljs-string">"SMTP_PASSWORD"</span>)

<span class="hljs-comment"># Example usage in an SMTP connection setup</span>
<span class="hljs-comment"># smtp.login(login, password)</span>
</code></pre>
<p>Note that you need to set the variables in your operating system prior to running the script. </p>
<h3 id="heading-refactor-the-script-to-use-html-emails">Refactor the script to use HTML emails</h3>
<p>HTML emails provide a better user experience. They allow you to include formatted text, images, tables, clickable links, and custom styling. This works great for marketing emails, newsletters, or any communication where design and branding matter. </p>
<p>So, to refactor the script, you would import ‘MIMEMultipart’ and ‘MIMEText’. This action allows you to customize the HTML emails yet keep the plain-text versions as a fallback if your recipients cannot open the HTML email. </p>
<p>Here’s the revised script:</p>
<pre><code class="lang-python"><span class="hljs-keyword">import</span> smtplib
<span class="hljs-keyword">from</span> email.mime.multipart <span class="hljs-keyword">import</span> MIMEMultipart
<span class="hljs-keyword">from</span> email.mime.text <span class="hljs-keyword">import</span> MIMEText

<span class="hljs-comment"># Configuration</span>
smtp_server = <span class="hljs-string">"live.smtp.mailtrap.io"</span>
port = <span class="hljs-number">587</span>
login = <span class="hljs-string">"api"</span>  <span class="hljs-comment"># Mailtrap login</span>
password = <span class="hljs-string">"1a2b3c4d5e6f7g"</span>  <span class="hljs-comment"># Mailtrap password</span>

sender_email = <span class="hljs-string">"mailtrap@example.com"</span>
receiver_email = <span class="hljs-string">"new@example.com"</span>

message = MIMEMultipart()
message[<span class="hljs-string">"From"</span>] = sender_email
message[<span class="hljs-string">"To"</span>] = receiver_email
message[<span class="hljs-string">"Subject"</span>] = <span class="hljs-string">"HTML Email"</span>

<span class="hljs-comment"># Add plain text content (optional, for email clients that don't render HTML)</span>
message.attach(MIMEText(<span class="hljs-string">"This is a plain text version of the email."</span>, <span class="hljs-string">"plain"</span>))

<span class="hljs-comment"># Add HTML content</span>
html_content = <span class="hljs-string">"""\
&lt;html&gt;
  &lt;body&gt;
    &lt;h1&gt;Welcome to Mailtrap!&lt;/h1&gt;
    &lt;p&gt;This is an example of an HTML email.&lt;/p&gt;
  &lt;/body&gt;
&lt;/html&gt;
"""</span>
message.attach(MIMEText(html_content, <span class="hljs-string">"html"</span>))

<span class="hljs-comment"># Send the email</span>
<span class="hljs-keyword">with</span> smtplib.SMTP(smtp_server, port) <span class="hljs-keyword">as</span> server:
    server.starttls()
    server.login(login, password)
    server.sendmail(sender_email, receiver_email, message.as_string())

print(<span class="hljs-string">'Sent'</span>)
</code></pre>
<p>Lastly, I’ve included video instructions for the SMTP method – so if that works better for you, feel free to check it out 🔽. </p>
<p><a target="_blank" href="https://www.youtube.com/watch?v=ufLpTc9up8s&amp;t=1s">How to send email in Python using Mailtrap - Tutorial by Mailtrap</a></p>
<h2 id="heading-how-to-send-emails-with-the-mailtrap-email-api">How to Send emails with the Mailtrap email API</h2>
<p>If you're looking to move beyond using SMTP for sending emails and want to integrate Mailtrap’s email API into your Python applications, this section will walk you through how to do that. </p>
<p>The Mailtrap <a target="_blank" href="https://mailtrap.io/smtp-api/">SMTP email API</a> allows you to send emails more efficiently, with added flexibility and scalability. Before starting, make sure you have a verified sending domain on Mailtrap and the Mailtrap API token, which you’ll use to authenticate requests.</p>
<p><strong>Note</strong>: I’m covering the API integration using the official Mailtrap Python SDK. </p>
<p>So, first you install the official SDK with the command below. </p>
<pre><code class="lang-python">pip install mailtrap
</code></pre>
<p><strong>Prerequisite</strong>: Ensure your Python package version is 3.6+ or higher. </p>
<p>After installing the SDK, the next step is to create a Mail object. This object will represent the email you want to send, including essential details like the sender, recipient, subject, and email content. </p>
<pre><code class="lang-python"><span class="hljs-keyword">import</span> mailtrap <span class="hljs-keyword">as</span> mt

<span class="hljs-comment"># Create the mail object</span>
mail = mt.Mail(
    sender=mt.Address(email=<span class="hljs-string">"mailtrap@example.com"</span>, name=<span class="hljs-string">"Mailtrap Test"</span>),  <span class="hljs-comment"># Sender info</span>
    to=[mt.Address(email=<span class="hljs-string">"your@email.com"</span>)],  <span class="hljs-comment"># Recipient info</span>
    subject=<span class="hljs-string">"You are awesome!"</span>,  <span class="hljs-comment"># Email subject</span>
    text=<span class="hljs-string">"Congrats for sending a test email with Mailtrap!"</span>  <span class="hljs-comment"># Email content (plain text)</span>
)

<span class="hljs-comment"># Create a client using your API key</span>
client = mt.MailtrapClient(token=<span class="hljs-string">"your-api-key"</span>)

<span class="hljs-comment"># Send the email</span>
client.send(mail)
</code></pre>
<p><strong>Quick notes:</strong></p>
<ul>
<li><p><strong>Sender and recipient</strong>: You need to specify the sender’s email address, which must match your verified domain. Similarly, define the recipient's email.</p>
</li>
<li><p><strong>Subject and text content</strong>: Set the subject and plain text content of the email. You can also add HTML content as I'll cover later.</p>
</li>
<li><p><strong>Client and sending</strong>: The ‘MailtrapClient’ is initialized with your Mailtrap API token, which authenticates the API request. The ‘send’ method is then called on the client, passing the ‘mail’ object.</p>
</li>
</ul>
<p>To create the client using the Mailtrap API token, take the following path within Mailtrap:<br><strong>Settings</strong> &gt; <strong>API Tokens</strong> &gt; <strong>Add Token</strong> </p>
<p><img src="https://lh7-rt.googleusercontent.com/docsz/AD_4nXeLlNbf0Uiub9YYVxcfiNsZL6_uNHKfuO4dW6ZZGXWEGkF7X4mw82KMsrAWX4hA_u_jYqi1G8aoh1-vOnxKjdXKackVG8HdrsyfHulzaIJVMrMcxmZvllXcNOXVxG7hFOJXgl2VBw?key=CJmzmKUWxlFjIw3A041wXvaj" alt="Add API tokens" width="600" height="400" loading="lazy"></p>
<p>With that, you can use the following command to send emails:</p>
<pre><code class="lang-python"><span class="hljs-comment"># create client and send</span>
client = mt.MailtrapClient(token=<span class="hljs-string">"your-api-key"</span>)
client.send(mail)
</code></pre>
<p>Finally, here’s the SDK script for sending a bare-bones ‘plaintext’ email via Python SDK.</p>
<pre><code class="lang-python"> <span class="hljs-keyword">from</span> mailtrap <span class="hljs-keyword">import</span> Mail, Address, MailtrapClient

<span class="hljs-comment"># Create a Mail object with basic details for a plain text email</span>
mail = Mail(
    <span class="hljs-comment"># Specify the sender's email address and optional name</span>
    sender=Address(email=<span class="hljs-string">"mailtrap@example.com"</span>, name=<span class="hljs-string">"Mailtrap Test"</span>),
    <span class="hljs-comment"># Specify one or more recipients; here we use a list with a single recipient</span>
    to=[Address(email=<span class="hljs-string">"your@email.com"</span>, name=<span class="hljs-string">"Your Name"</span>)],
    <span class="hljs-comment"># Subject of the email</span>
    subject=<span class="hljs-string">"Simple Plain Text Email"</span>,
    <span class="hljs-comment"># The plain text content of the email</span>
    text=<span class="hljs-string">"This is a plain text email sent using the Mailtrap SDK. Simple and straightforward."</span>,
    <span class="hljs-comment"># Optional: categorize this email for easier sorting or management in the Mailtrap service</span>
    category=<span class="hljs-string">"Test"</span>,
    <span class="hljs-comment"># Optional: Additional headers can be specified, but are not required for plain text emails</span>
    headers={<span class="hljs-string">"X-Example-Header"</span>: <span class="hljs-string">"HeaderValue"</span>}
)

<span class="hljs-comment"># Initialize the MailtrapClient with your API token</span>
client = MailtrapClient(token=<span class="hljs-string">"your-api-key"</span>)

<span class="hljs-comment"># Send the email using the client's send method</span>
client.send(mail)

print(<span class="hljs-string">"Plain text email sent successfully."</span>)
</code></pre>
<p><strong>In the script</strong>:</p>
<ul>
<li><p>The imported classes include ‘MailtrapClient’, ‘Mail’, and ‘Address’ because I’m sending a plain text message. </p>
</li>
<li><p>The ‘Mail’ object contains:</p>
<ul>
<li><p>‘Mail’ constructor to create the object.</p>
</li>
<li><p>‘Sender’ which uses ‘Address’ class to define the name and email of the sender.</p>
</li>
<li><p>‘to’ which is typically an ‘Address’ objects list, but since this is a plain text email, it usually has direct recipients instead of the list. </p>
</li>
<li><p>‘subject’ which is the subject of the email. </p>
</li>
<li><p>‘text’ which contains the email content (in ‘plaintext’)</p>
</li>
<li><p>‘headers’ and ‘category’ which are optional fields that help better manage your emails. </p>
</li>
</ul>
</li>
</ul>
<ul>
<li><p>The email sending flow:</p>
<ul>
<li><p>‘MailtrapClient’ gets created and authenticated via the API token. </p>
</li>
<li><p>The ‘MailtrapClient’ ‘send’ method gets called and passes the ‘mail’ object as an email-sending argument.  </p>
</li>
<li><p>The “Plain text email sent successfully.” message gets printed to confirm the action. </p>
</li>
</ul>
</li>
</ul>
<h3 id="heading-refactor-the-script-to-include-html-and-attachments">Refactor the script to include HTML and attachments</h3>
<p>Again, it’s pretty straightforward to refactor the script using the ‘MIMEMultipart’ class for more complex email structures. </p>
<p>Here’s the refactored code:</p>
<pre><code class="lang-python"><span class="hljs-keyword">import</span> mailtrap <span class="hljs-keyword">as</span> mt
<span class="hljs-keyword">from</span> email.mime.multipart <span class="hljs-keyword">import</span> MIMEMultipart
<span class="hljs-keyword">from</span> email.mime.text <span class="hljs-keyword">import</span> MIMEText

<span class="hljs-comment"># Create a multipart email message</span>
message = MIMEMultipart()
message[<span class="hljs-string">"Subject"</span>] = <span class="hljs-string">"HTML Email"</span>

<span class="hljs-comment"># Plain text version (for email clients that don't support HTML)</span>
message.attach(MIMEText(<span class="hljs-string">"This is the plain text version."</span>, <span class="hljs-string">"plain"</span>))

<span class="hljs-comment"># HTML version</span>
html_content = <span class="hljs-string">"""\
&lt;html&gt;
  &lt;body&gt;
    &lt;h1&gt;Welcome to Mailtrap!&lt;/h1&gt;
    &lt;p&gt;This is an HTML email with some &lt;b&gt;bold text&lt;/b&gt; and a &lt;a href="https://example.com"&gt;link&lt;/a&gt;.&lt;/p&gt;
  &lt;/body&gt;
&lt;/html&gt;
"""</span>
message.attach(MIMEText(html_content, <span class="hljs-string">"html"</span>))

client = mt.MailtrapClient(token=<span class="hljs-string">"your-api-key"</span>)

<span class="hljs-comment"># Now send the email with Mailtrap's API</span>
mail = mt.Mail(
    sender=mt.Address(email=<span class="hljs-string">"mailtrap@example.com"</span>, name=<span class="hljs-string">"Mailtrap Test"</span>),
    to=[mt.Address(email=<span class="hljs-string">"your@email.com"</span>)],
    subject=<span class="hljs-string">"You are awesome!"</span>,
    html=message.as_string()  <span class="hljs-comment"># Pass the HTML content as a string</span>
)
client.send(mail)
</code></pre>
<h3 id="heading-environmental-setup-for-production">Environmental setup for production</h3>
<p>Before I dive into the details, I’d like to remind you of security best practices:</p>
<ol>
<li><p><strong>Securely store API keys and credentials</strong>: On production, never hardcode sensitive data like API keys, email login credentials, or other secrets directly into your source code. Doing so exposes your application.</p>
</li>
<li><p><strong>Use environment variables</strong>: By doing this, you can keep your credentials safe and easily switch between different configurations (like dev, staging, and production). </p>
</li>
</ol>
<p>Now, here’s how to set it all up:</p>
<ol>
<li><p>Use the ‘python-dotenv’ package to load environment variables from a ‘.env’ file. Install the lib with the following command:</p>
<pre><code class="lang-python"> pip install python-dotenv
</code></pre>
</li>
</ol>
<ol start="2">
<li><p>Create a ‘.env’ file in the root of your project to store your environment variables securely. This file will contain sensitive information, such as your Mailtrap API key, login credentials, and SMTP server details. Here’s an example:</p>
<pre><code class="lang-python"> SMTP_SERVER=smtp.mailtrap.io
 SMTP_PORT=<span class="hljs-number">587</span>
 SMTP_LOGIN=your_mailtrap_login
 SMTP_PASSWORD=your_mailtrap_password
 MAILTRAP_API_KEY=your_mailtrap_api_key
</code></pre>
</li>
</ol>
<p><strong>Important note</strong>: Ensure this ‘.env’ file is never pushed to version control (like Git). Add it to your ‘.gitignore’ to avoid accidental exposure.</p>
<ol start="3">
<li><p>Once you've created your ‘.env’ file, you need to load the variables into your Python script. At the top of your script, import the ‘dotenv’ package and call ‘load_dotenv()’ to load the environment variables.</p>
<pre><code class="lang-python"> <span class="hljs-keyword">from</span> dotenv <span class="hljs-keyword">import</span> load_dotenv
 <span class="hljs-keyword">import</span> os

 <span class="hljs-comment"># Load environment variables from the .env file</span>
 load_dotenv()

 <span class="hljs-comment"># Retrieve environment variables securely</span>
 smtp_server = os.getenv(<span class="hljs-string">"SMTP_SERVER"</span>)
 smtp_port = os.getenv(<span class="hljs-string">"SMTP_PORT"</span>)
 smtp_login = os.getenv(<span class="hljs-string">"SMTP_LOGIN"</span>)
 smtp_password = os.getenv(<span class="hljs-string">"SMTP_PASSWORD"</span>)
 mailtrap_api_key = os.getenv(<span class="hljs-string">"MAILTRAP_API_KEY"</span>)
</code></pre>
</li>
</ol>
<ol start="4">
<li><p>With the environment variables loaded, you can replace the hardcoded credentials in the script with these environment variables. Here’s an example:</p>
<pre><code class="lang-python"> <span class="hljs-keyword">import</span> smtplib
 <span class="hljs-keyword">from</span> email.mime.text <span class="hljs-keyword">import</span> MIMEText
 <span class="hljs-keyword">from</span> dotenv <span class="hljs-keyword">import</span> load_dotenv
 <span class="hljs-keyword">import</span> os

 <span class="hljs-comment"># Load environment variables</span>
 load_dotenv()

 <span class="hljs-comment"># Fetching SMTP credentials from environment variables</span>
 smtp_server = os.getenv(<span class="hljs-string">"SMTP_SERVER"</span>)
 smtp_port = os.getenv(<span class="hljs-string">"SMTP_PORT"</span>)
 smtp_login = os.getenv(<span class="hljs-string">"SMTP_LOGIN"</span>)
 smtp_password = os.getenv(<span class="hljs-string">"SMTP_PASSWORD"</span>)

 sender_email = <span class="hljs-string">"mailtrap@example.com"</span>
 receiver_email = <span class="hljs-string">"new@example.com"</span>
 subject = <span class="hljs-string">"Plain text email"</span>
 text = <span class="hljs-string">"""\
 Hi,
 Check out the new post on the Mailtrap blog:
 https://blog.mailtrap.io/2018/09/27/cloud-or-local-smtp-server/
 """</span>

 <span class="hljs-comment"># Create MIMEText object</span>
 message = MIMEText(text, <span class="hljs-string">"plain"</span>)
 message[<span class="hljs-string">"Subject"</span>] = subject
 message[<span class="hljs-string">"From"</span>] = sender_email
 message[<span class="hljs-string">"To"</span>] = receiver_email

 <span class="hljs-comment"># Send email using environment variables</span>
 <span class="hljs-keyword">with</span> smtplib.SMTP(smtp_server, smtp_port) <span class="hljs-keyword">as</span> server:
     server.starttls()  <span class="hljs-comment"># Secure the connection</span>
     server.login(smtp_login, smtp_password)
     server.sendmail(sender_email, receiver_email, message.as_string())

 print(<span class="hljs-string">"Email sent successfully!"</span>)
</code></pre>
</li>
</ol>
<h4 id="heading-pro-tips">Pro tips:</h4>
<p>First, ensure your environment variables are only accessible to authorized users. On a production server, this typically means only allowing access to the environment variables through the deployment configuration (for example, through Heroku’s config vars, AWS Secrets Manager, or other cloud-based secret management tools).</p>
<p>Second, use different environment variables for development, staging, and production. This ensures that your production environment is isolated and secured from the rest of your development process.</p>
<p>Once your environment variables are configured locally, deploy your application to a production environment. Make sure to set the same environment variables in your production server or service.</p>
<p>If you're deploying to platforms like Heroku, AWS, or Google Cloud, you can use their environment variable management tools to securely store and access your secrets without having to manage a ‘.env’ file manually.</p>
<h2 id="heading-wrapping-up">Wrapping up</h2>
<p>This quick tutorial provides more than enough to get started with sending emails in Python. And note that the scripts featured above can be extended to include HTML, multiple recipients, attachments, images, and so on. </p>
<p>If you’re interested in that and more security tips and best practices, you can check out the Mailtrap blog for more detailed tutorials.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Implement API Rate Limiting in Strapi CMS ]]>
                </title>
                <description>
                    <![CDATA[ Implementing rate limiting in web applications is a necessary web development best practice. In an article published earlier, I delved deep into the benefits and real life use cases of API rate limiting. Some of the benefits include its use by develo... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/implement-api-rate-limiting-in-strapi/</link>
                <guid isPermaLink="false">66e05529fcb93f325519038c</guid>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwatobi ]]>
                </dc:creator>
                <pubDate>Tue, 10 Sep 2024 14:18:17 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1725233479497/7c12e6e4-a6d7-433a-b23b-f25c33037ffa.jpeg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Implementing rate limiting in web applications is a necessary web development best practice. In an <a target="_blank" href="https://www.freecodecamp.org/news/what-is-rate-limiting-web-apis/">article</a> published earlier, I delved deep into the benefits and real life use cases of API rate limiting.</p>
<p>Some of the benefits include its use by developers to restrict malicious access to websites, prevent DDoS attacks, conserve website resources, and ensure optimal web server performance.</p>
<p>This article covers the practical aspects of implementing rate limits in a Strapi application using several packages and techniques.</p>
<p>Let's get started.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-demo-project">Demo Project</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-koa2-rate-limit">Koa Rate Limiter</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-custom-strapi-api-rate-limiter">Custom Strapi Api Rate Limiter</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-express-rate-limiter-implementation">Express-rate-limiter Implementation</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-demo-project">Demo Project</h2>
<p>We'll be building an e-commerce site using <a target="_blank" href="https://strapi.io/">Strapi</a> as our backend framework. We'll then set up a rate limiter in our Strapi application to help guarantee our backend security. Postman will serve as our tool for testing the API endpoints. Let's go on to create a default Strapi application.</p>
<p>To create a strapi application, enter <code>npx create-strapi-app@latest {project name}</code> on the command line and follow the commands provided. To make the installation more straightforward, stick with the <em>quick start</em> installation method and your app should be ready.</p>
<p>This installation modality automatically sets up an easy-to-use SQLite database. However, you could choose to use any other SQL database supported by Strapi.</p>
<p>Alternatively, you can download the starter repo for the project from <a target="_blank" href="https://github.com/oluwatobi2001/Strapi-default">here</a> and install the necessary dependencies via <code>npm install</code>. Thereafter, you can execute the Strapi application by navigating to the Strapi application code folder on the command line and run <code>npm run develop</code>.</p>
<p><img src="https://hackmd.io/_uploads/BkRn2PqrR.png" alt="Strapi Setup" width="600" height="400" loading="lazy"></p>
<p>On successful execution, you will be provided with the link to the localhost address to customize the application.</p>
<p><img src="https://hackmd.io/_uploads/SkkSavcS0.png" alt="Strapi launch" width="600" height="400" loading="lazy"></p>
<p>Navigating to the link will require you to create an admin login mail and password. Successful completion of this step will give you access to the backend dashboard.</p>
<p><img src="https://hackmd.io/_uploads/S1Vqxd5B0.png" alt="strapi login UI" width="600" height="400" loading="lazy"></p>
<p>You can utilize the Strapi dashboard UI to create APIs, or you can generate an API using <code>npm generate</code>. The APIs created will be used in completing the setup for the rate limiting functionality. We will be creating a product store for our e-commerce site. To easily set up products, kindly navigate to the Content-Type builder tab on the sidebar.  </p>
<p><img src="https://hackmd.io/_uploads/r1RzbO5BC.png" alt="strapi dashboard" width="600" height="400" loading="lazy"></p>
<p>The content-Type builder manager allows you to create various collections which will come in handy when setting up your APIs. In this case, the product and category collections will be created to enable you set up your product catalogues.</p>
<p><img src="https://hackmd.io/_uploads/B16rbu5rA.png" alt="Creating a category endpoint" width="600" height="400" loading="lazy"></p>
<p><img src="https://hackmd.io/_uploads/SJhdb_qSR.png" alt="Creating a product entry" width="600" height="400" loading="lazy"></p>
<p>After completing the creation of the collection types, you can easily add your products seamlessly into the backend database. In my case, I created phone brand products for sale.</p>
<p><img src="https://hackmd.io/_uploads/HyR9JT6fR.jpg" alt="Product creation demo" width="600" height="400" loading="lazy"></p>
<p>Also noteworthy is that the collections we created in the Strapi dashboard automatically creates an API folder for us within our codebase. We will then be working on the project codebase subsequently.</p>
<p>The next step in this tutorial is to set up an efficient rate limiter for our Strapi APIs created in the repo using the tools discussed above.</p>
<h2 id="heading-koa2-rate-limit">koa2-rate-limit</h2>
<p>In this section, we will be using the koa2-rate-limit package to build our project rate limiter. To install the package, navigate to your project folder on the command line and execute <code>npm i koa2-rate-limit</code>. On successful installation, navigate to the middleware subfolder within the API folder and create a code file. For ease of integration, name it as <strong>rateLimit.js</strong>.</p>
<p>After that, within the rate limit file, import and initialize the koa2-rate limit package.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> RateLimit = <span class="hljs-built_in">require</span>(<span class="hljs-string">"koa2-ratelimit"</span>).RateLimit;
</code></pre>
<p>Afterwards, we can configure the koa rate limiter to a specified time interval frame and the total number of requests.</p>
<pre><code class="lang-javascript"><span class="hljs-built_in">module</span>.exports = <span class="hljs-function">(<span class="hljs-params">config, { strapi }</span>) =&gt;</span> {
  <span class="hljs-comment">// Configuring the rate limiter middleware</span>
  <span class="hljs-keyword">const</span> limiter = RateLimit.middleware({
    <span class="hljs-attr">interval</span>: { <span class="hljs-attr">min</span>: <span class="hljs-number">1</span> }, <span class="hljs-comment">// Time window in minutes</span>
    <span class="hljs-attr">max</span>: <span class="hljs-number">3</span>, <span class="hljs-comment">// Maximum number of requests per interval</span>
 });
</code></pre>
<p>In the code above, the rate limiter middleware was invoked and the time interval in which the rate limit gets applied was set to 1 minute. The maximum number of requests (max) was set to 3 for this tutorial. You can tweak this to suit your preference.</p>
<pre><code class="lang-javascript">  <span class="hljs-keyword">return</span> <span class="hljs-keyword">async</span> (ctx, next) =&gt; {


    <span class="hljs-keyword">try</span> {
      <span class="hljs-comment">// Apply the rate limiter to the current request</span>
      <span class="hljs-keyword">await</span> limiter(ctx, next);
 } <span class="hljs-keyword">catch</span> (err) {
      <span class="hljs-keyword">if</span> (err.status === <span class="hljs-number">429</span>) {
        <span class="hljs-comment">// Handle rate limit exceeded error</span>
        strapi.log.warn(<span class="hljs-string">'Rate limit exceeded.'</span>);
        ctx.status = <span class="hljs-number">429</span>;
        ctx.body = {
          <span class="hljs-attr">statusCode</span>: <span class="hljs-number">429</span>,
          <span class="hljs-attr">error</span>: <span class="hljs-string">'Too Many Requests'</span>,
          <span class="hljs-attr">message</span>: <span class="hljs-string">'You have exceeded the maximum number of requests. Please try again later.'</span>,
 };
 } <span class="hljs-keyword">else</span> {
        <span class="hljs-comment">// Re-throw other errors to be handled by Strapi's error-handling middleware</span>
        <span class="hljs-keyword">throw</span> err;
 }
 }
</code></pre>
<p>The code above defines a middleware which gets executed whenever a function is made on any API. If the requests exceed the given maximum, an error code is outputted. Below is the full code.</p>
<pre><code class="lang-javascript"><span class="hljs-meta">
'use strict'</span>;

<span class="hljs-comment">/**
 * `RateLimit` middleware
 */</span>
<span class="hljs-keyword">const</span> RateLimit = <span class="hljs-built_in">require</span>(<span class="hljs-string">"koa2-ratelimit"</span>).RateLimit;

<span class="hljs-built_in">module</span>.exports = <span class="hljs-function">(<span class="hljs-params">config, { strapi }</span>) =&gt;</span> {
  <span class="hljs-comment">// Configuring the rate limiter middleware</span>
  <span class="hljs-keyword">const</span> limiter = RateLimit.middleware({
    <span class="hljs-attr">interval</span>: { <span class="hljs-attr">min</span>: <span class="hljs-number">1</span> }, <span class="hljs-comment">// Time window in minutes</span>
    <span class="hljs-attr">max</span>: <span class="hljs-number">3</span>, <span class="hljs-comment">// Maximum number of requests per interval</span>
 });

  <span class="hljs-keyword">return</span> <span class="hljs-keyword">async</span> (ctx, next) =&gt; {

    <span class="hljs-keyword">try</span> {
      <span class="hljs-comment">// Apply the rate limiter to the current request</span>
      <span class="hljs-keyword">await</span> limiter(ctx, next);
 } <span class="hljs-keyword">catch</span> (err) {
      <span class="hljs-keyword">if</span> (err.status === <span class="hljs-number">429</span>) {
        <span class="hljs-comment">// Handle rate limit exceeded error</span>
        strapi.log.warn(<span class="hljs-string">'Rate limit exceeded.'</span>);
        ctx.status = <span class="hljs-number">429</span>;
        ctx.body = {
          <span class="hljs-attr">statusCode</span>: <span class="hljs-number">429</span>,
          <span class="hljs-attr">error</span>: <span class="hljs-string">'Too Many Requests'</span>,
          <span class="hljs-attr">message</span>: <span class="hljs-string">'You have exceeded the maximum number of requests. Please try again later.'</span>,
 };
 } <span class="hljs-keyword">else</span> {
        <span class="hljs-comment">// Re-throw other errors to be handled by Strapi's error-handling middleware</span>
        <span class="hljs-keyword">throw</span> err;
 }
 }

 };
};
</code></pre>
<p>To ensure its seamless integration to all APIs within the Strapi project, the admin middlewares must also be configured.</p>
<pre><code class="lang-javascript">cconst rateLimit = <span class="hljs-built_in">require</span>(<span class="hljs-string">'../middlewares/rateLimit'</span>);

<span class="hljs-built_in">module</span>.exports = [
 <span class="hljs-string">'strapi::logger'</span>,
 <span class="hljs-string">'strapi::errors'</span>,
 <span class="hljs-string">'strapi::security'</span>,
 <span class="hljs-string">'strapi::cors'</span>,
 <span class="hljs-string">'strapi::poweredBy'</span>,
 <span class="hljs-string">'strapi::query'</span>,
 <span class="hljs-string">'strapi::body'</span>,
 <span class="hljs-string">'strapi::session'</span>,
 <span class="hljs-string">'strapi::favicon'</span>,
 <span class="hljs-string">'strapi::public'</span>,

 {
   <span class="hljs-attr">name</span>: <span class="hljs-string">'global::rateLimit'</span>,
   <span class="hljs-attr">config</span>: {},
 },
];
</code></pre>
<p>With this, we have successfully configured the rate limiter powered by koa2-ratelimiter. Here are pictures of its execution.</p>
<p><img src="https://hackmd.io/_uploads/Bybbd-hj0.png" alt="Postman testing the categories endpoint" width="600" height="400" loading="lazy"></p>
<p><img src="https://hackmd.io/_uploads/r1Zb_-3jC.png" alt="rate limiting error response output" width="600" height="400" loading="lazy"></p>
<h2 id="heading-custom-strapi-api-rate-limiter">Custom Strapi Api Rate Limiter</h2>
<p>Within the <strong>rateLimit</strong> file in the <strong>API/middlewares</strong> folder, create a custom rate limiter by initializing a memory store.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> requestCounts = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Map</span>();
</code></pre>
<p>Thereafter, define your rate limit function and then configure the rate limiter.</p>
<pre><code class="lang-javascript"><span class="hljs-built_in">module</span>.exports = <span class="hljs-function">(<span class="hljs-params">config, { strapi }</span>) =&gt;</span> {

  <span class="hljs-keyword">const</span> rateLimitConfig = strapi.config.get(<span class="hljs-string">'admin.rateLimit'</span>, {
    <span class="hljs-attr">interval</span>: <span class="hljs-number">60</span> * <span class="hljs-number">1000</span>,  
    <span class="hljs-attr">max</span>: <span class="hljs-number">3</span>,  
 });
</code></pre>
<p>The time interval above is 1 minute while the maximum number of requests that can be made within the specified time interval is 3. You can tweak it to suit your preference.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">return</span> <span class="hljs-keyword">async</span> (ctx, next) =&gt; {

    <span class="hljs-keyword">const</span> ip = ctx.ip; 
    <span class="hljs-keyword">const</span> currentTime = <span class="hljs-built_in">Date</span>.now();

    <span class="hljs-keyword">if</span> (!requestCounts.has(ip)) {

      requestCounts.set(ip, { <span class="hljs-attr">count</span>: <span class="hljs-number">1</span>, <span class="hljs-attr">startTime</span>: currentTime });
 } <span class="hljs-keyword">else</span> {
      <span class="hljs-keyword">const</span> requestInfo = requestCounts.get(ip);


      <span class="hljs-keyword">if</span> (currentTime - requestInfo.startTime &gt; rateLimitConfig.interval) {
        requestInfo.count = <span class="hljs-number">1</span>;
        requestInfo.startTime = currentTime;
 } <span class="hljs-keyword">else</span> {

 }


      <span class="hljs-keyword">if</span> (requestInfo.count &gt; rateLimitConfig.max) {
        strapi.log.warn(<span class="hljs-string">`Rate limit exceeded for IP: <span class="hljs-subst">${ip}</span>`</span>);

        ctx.status = <span class="hljs-number">429</span>;
        ctx.body = {
          <span class="hljs-attr">statusCode</span>: <span class="hljs-number">429</span>,
          <span class="hljs-attr">error</span>: <span class="hljs-string">'Too Many Requests'</span>,
          <span class="hljs-attr">message</span>: <span class="hljs-string">'You have exceeded the maximum number of requests. Please try again later.'</span>,
 };
        <span class="hljs-keyword">return</span>;
 }
 }

    <span class="hljs-keyword">await</span> next();
 };
};
</code></pre>
<p>Afterwards, a middleware is defined which obtains the user IP address and then stores it in the memory store. The time interval is also set from the current time the request is made and the request count gets updated with every new request made.</p>
<p>If the requests made exceed the maximum expected requests within the time interval of 1 minute in our case, an error is thrown. Here is the full code below.</p>
<pre><code class="lang-javascript"><span class="hljs-meta">'use strict'</span>;
<span class="hljs-keyword">const</span> requestCounts = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Map</span>();

<span class="hljs-built_in">module</span>.exports = <span class="hljs-function">(<span class="hljs-params">config, { strapi }</span>) =&gt;</span> {

  <span class="hljs-keyword">const</span> rateLimitConfig = strapi.config.get(<span class="hljs-string">'admin.rateLimit'</span>, {
    <span class="hljs-attr">interval</span>: <span class="hljs-number">60</span> * <span class="hljs-number">1000</span>,  
    <span class="hljs-attr">max</span>: <span class="hljs-number">3</span>,  
 });

  <span class="hljs-keyword">return</span> <span class="hljs-keyword">async</span> (ctx, next) =&gt; {

    <span class="hljs-keyword">const</span> ip = ctx.ip; 
    <span class="hljs-keyword">const</span> currentTime = <span class="hljs-built_in">Date</span>.now();

    <span class="hljs-keyword">if</span> (!requestCounts.has(ip)) {

      requestCounts.set(ip, { <span class="hljs-attr">count</span>: <span class="hljs-number">1</span>, <span class="hljs-attr">startTime</span>: currentTime });
 } <span class="hljs-keyword">else</span> {
      <span class="hljs-keyword">const</span> requestInfo = requestCounts.get(ip);


      <span class="hljs-keyword">if</span> (currentTime - requestInfo.startTime &gt; rateLimitConfig.interval) {
        requestInfo.count = <span class="hljs-number">1</span>;
        requestInfo.startTime = currentTime;
 } <span class="hljs-keyword">else</span> {

        requestInfo.count += <span class="hljs-number">1</span>;
 }


      <span class="hljs-keyword">if</span> (requestInfo.count &gt; rateLimitConfig.max) {


        ctx.status = <span class="hljs-number">429</span>;
        ctx.body = {
          <span class="hljs-attr">statusCode</span>: <span class="hljs-number">429</span>,
          <span class="hljs-attr">error</span>: <span class="hljs-string">'Too Many Requests'</span>,
          <span class="hljs-attr">message</span>: <span class="hljs-string">'You have exceeded the maximum number of requests. Please try again later.'</span>,
 };
        <span class="hljs-keyword">return</span>;
 }
 }

    <span class="hljs-keyword">await</span> next();
 };
};
</code></pre>
<p>Here is a demo of the project.</p>
<p><img src="https://hackmd.io/_uploads/BkIyHZ2j0.png" alt="fetching the categories on Postman" width="600" height="400" loading="lazy"></p>
<p><img src="https://hackmd.io/_uploads/HyxgHW2i0.png" alt="rate limiting error on Postman" width="600" height="400" loading="lazy"></p>
<h3 id="heading-express-rate-limiter-implementation">Express-rate-limiter Implementation</h3>
<p>Express rate limiter is also another important package that can be used to implement rate limiting in our project. Right now, this package will be used to implement a route-specific API rate limiting.</p>
<p>The next step in this tutorial is setting up an efficient rate limiter for our Strapi APIs created in the repo.</p>
<p>To set up rate limiters on our Strapi applications, we'll be working mainly on the <strong>routes</strong> file. This can be navigated to by accessing the <strong>src</strong> folder within the project root directory. Within the <strong>src</strong> folder, navigate to the <strong>API</strong> folder which contains all the API files for the collections created in the Strapi dashboard.</p>
<p><img src="https://hackmd.io/_uploads/S1ERbxndR.png" alt="the product route directory" width="600" height="400" loading="lazy"></p>
<p>The rate limiter will be enforced in the routes section of each API. For this tutorial, I will be using the products API as a demo API in this article.</p>
<pre><code class="lang-javascript"><span class="hljs-meta">'use strict'</span>;


<span class="hljs-comment">/**
 * product router
 */</span>

<span class="hljs-keyword">const</span> { createCoreRouter } = <span class="hljs-built_in">require</span>(<span class="hljs-string">'@strapi/strapi'</span>).factories;

<span class="hljs-built_in">module</span>.exports = createCoreRouter(<span class="hljs-string">'api::product.product'</span>);
</code></pre>
<p>This is the initial code setup in the <strong>routes.js</strong> file in our product API folder. The rate limiting tool of choice for this tutorial is express-rate-limit as it offers much simplicity and user-friendliness coupled with its efficiency. Here is a link to its <a target="_blank" href="https://www.npmjs.com/package/express-rate-limit">documentation</a>. To get this installed, navigate to the command line of the project directory and run</p>
<pre><code class="lang-bash">npm install express-rate-limit
</code></pre>
<p>On completion of its installation, we will be initializing it in the <strong>products</strong> file already created within the <strong>routes</strong> folder as follows.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> { rateLimit } = <span class="hljs-built_in">require</span>(<span class="hljs-string">"express-rate-limit"</span>);
</code></pre>
<p>Go on and configure the rate limiter to your desired specifications.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> rateLimit = <span class="hljs-built_in">require</span>(<span class="hljs-string">'express-rate-limit'</span>);

<span class="hljs-keyword">const</span> limiter = rateLimit({
  <span class="hljs-attr">windowMs</span>: <span class="hljs-number">3</span> * <span class="hljs-number">60</span> * <span class="hljs-number">1000</span>, <span class="hljs-comment">// 3 minutes</span>
  <span class="hljs-attr">max</span>: <span class="hljs-number">2</span>, <span class="hljs-comment">// limit each IP to 2 requests per windowMs</span>
  <span class="hljs-attr">handler</span>: <span class="hljs-keyword">async</span> (req, res, next) =&gt; {
    <span class="hljs-keyword">const</span> ctx = strapi.requestContext.get();
    ctx.status = <span class="hljs-number">429</span>;
    ctx.body = {
      <span class="hljs-attr">message</span>: <span class="hljs-string">"Too many requests"</span>,
      <span class="hljs-attr">policy</span>: <span class="hljs-string">"rate limit"</span>
    };
    <span class="hljs-comment">// Ensure the response is ended after setting the response body and status</span>
    ctx.res.end();
  }
});

<span class="hljs-built_in">module</span>.exports = limiter;
</code></pre>
<p>The code above serves to configure the rate limiting parameters we intend to use for the file.</p>
<p><code>windowMs</code> represents the time interval in milliseconds for the number of requests. In our case, we specified a time of 3 minutes. Also, we specified the maximum number of requests that can be made within that same time frame. In our case, we used 2 for demo purposes.</p>
<p>However, the <code>limit</code> parameter also serves as an alternative to <code>max</code> parameter. Also included is the handler function that gets executed whenever the requests exceed the set number. It returns an <strong>Error 429</strong> with an error body containing “Too many requests”.</p>
<pre><code class="lang-javascript">
<span class="hljs-keyword">const</span> { createCoreRouter } = <span class="hljs-built_in">require</span>(<span class="hljs-string">'@strapi/strapi'</span>).factories;

<span class="hljs-built_in">module</span>.exports = createCoreRouter(<span class="hljs-string">'api::product.product'</span>, {
  <span class="hljs-attr">config</span>: {
    <span class="hljs-attr">find</span>: {
      <span class="hljs-attr">middlewares</span>: [
        <span class="hljs-keyword">async</span> (ctx, next) =&gt; {
          <span class="hljs-keyword">await</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Promise</span>(<span class="hljs-function">(<span class="hljs-params">resolve, reject</span>) =&gt;</span> {
            limiter(ctx.req, ctx.res, <span class="hljs-function">(<span class="hljs-params">error</span>) =&gt;</span> {
              <span class="hljs-keyword">if</span> (error) {
                ctx.status = <span class="hljs-number">429</span>;
                ctx.body = { <span class="hljs-attr">error</span>: error.message };
                reject(error);
              } <span class="hljs-keyword">else</span> {
                resolve();
              }
            });
          });
          <span class="hljs-keyword">await</span> next();
        }
      ]
    }
  }
});
</code></pre>
<p>The above code illustrates the use of the Strapi API middleware which serves to ensure that the rate limit is fulfilled before the onward execution of the API requests. It also ensures that the request is terminated when the rate limit gets exceeded. Here is the final code for the project.</p>
<pre><code class="lang-javascript"><span class="hljs-meta">'use strict'</span>;

<span class="hljs-comment">/**
 * product router
 */</span>

<span class="hljs-keyword">const</span> { createCoreRouter } = <span class="hljs-built_in">require</span>(<span class="hljs-string">'@strapi/strapi'</span>).factories;
<span class="hljs-keyword">const</span> rateLimit = <span class="hljs-built_in">require</span>(<span class="hljs-string">'express-rate-limit'</span>);

<span class="hljs-keyword">const</span> limiter = rateLimit({
  <span class="hljs-attr">windowMs</span>: <span class="hljs-number">3</span> * <span class="hljs-number">60</span> * <span class="hljs-number">1000</span>, <span class="hljs-comment">// 3 minutes</span>
  <span class="hljs-attr">max</span>: <span class="hljs-number">2</span>, <span class="hljs-comment">// limit each IP to 2 requests per windowMs</span>
  <span class="hljs-attr">handler</span>: <span class="hljs-keyword">async</span> (req, res, next) =&gt; {
    <span class="hljs-keyword">const</span> ctx = strapi.requestContext.get();
    ctx.status = <span class="hljs-number">429</span>;
    ctx.body = {
      <span class="hljs-attr">message</span>: <span class="hljs-string">'Too many requests'</span>,
      <span class="hljs-attr">policy</span>: <span class="hljs-string">'rate limit'</span>
    };
    <span class="hljs-comment">// Ensure the response is ended after setting the response body and status</span>
    ctx.res.end();
  }
});

<span class="hljs-built_in">module</span>.exports = createCoreRouter(<span class="hljs-string">'api::product.product'</span>, {
  <span class="hljs-attr">config</span>: {
    <span class="hljs-attr">find</span>: {
      <span class="hljs-attr">middlewares</span>: [
        <span class="hljs-keyword">async</span> (ctx, next) =&gt; {
          <span class="hljs-keyword">await</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Promise</span>(<span class="hljs-function">(<span class="hljs-params">resolve, reject</span>) =&gt;</span> {
            limiter(ctx.req, ctx.res, <span class="hljs-function">(<span class="hljs-params">error</span>) =&gt;</span> {

              <span class="hljs-keyword">if</span> (error) {
                ctx.status = <span class="hljs-number">429</span>;
                ctx.body = { <span class="hljs-attr">error</span>: error.message };
                reject(error);
              } <span class="hljs-keyword">else</span> {
                resolve();
              }
            });
          });
          <span class="hljs-keyword">if</span> (ctx.status !== <span class="hljs-number">429</span>) {
            <span class="hljs-keyword">await</span> next();
          }
        }
      ]
    }
  }
});
</code></pre>
<p>Here is an image showing the rate limiting functionality.</p>
<p><img src="https://hackmd.io/_uploads/S116Wu9BR.png" alt="product endpoint testing in Postman" width="600" height="400" loading="lazy"></p>
<p><img src="https://hackmd.io/_uploads/S1zMGO5B0.png" alt="ratelimit successfully executed" width="600" height="400" loading="lazy"></p>
<p>You can also download the final code for the project <a target="_blank" href="https://github.com/oluwatobi2001/Strapi-project">here</a>. Having completed this, you can then go ahead to test the rate limiting functionality of your API. The Strapi application can be run by executing <code>npm run develop</code> in the command line.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>With this, we have come to the end of the tutorial. We hope you’ve learned essentially about rate limiting, its uses, tools and best practices.</p>
<p>You can also design multiple rate limiters within the code and implement them in any endpoint of your choice to test it out.</p>
<p>Feel free to drop any questions or comments. Happy coding!</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
